Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d37f0ca09 | ||
|
|
bfdbc937c6 |
@@ -1075,7 +1075,7 @@ unconditionally against whatever files it finds:
|
|||||||
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
||||||
`Entrypoint`, `Target`
|
`Entrypoint`, `Target`
|
||||||
- each event database when it is lazily opened — `Event`, `Delivery`,
|
- each event database when it is lazily opened — `Event`, `Delivery`,
|
||||||
`DeliveryResult`
|
`DeliveryResult`, `EventTotals`, `TargetTotals`
|
||||||
- each archive database on every open and reopen
|
- each archive database on every open and reopen
|
||||||
|
|
||||||
There is no schema version table, no migration ledger, and no down
|
There is no schema version table, no migration ledger, and no down
|
||||||
@@ -1392,7 +1392,7 @@ The codebase uses consistent naming throughout (rename completed in
|
|||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
webhooker's data model has nine entities organized into two tiers: the
|
webhooker's data model has eleven entities organized into two tiers: the
|
||||||
**application tier** (user and webhook configuration) and the **event
|
**application tier** (user and webhook configuration) and the **event
|
||||||
tier** (event ingestion, delivery, and logging).
|
tier** (event ingestion, delivery, and logging).
|
||||||
|
|
||||||
@@ -1421,6 +1421,13 @@ tier** (event ingestion, delivery, and logging).
|
|||||||
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
||||||
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
||||||
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ (one row: running counts of events) │
|
||||||
|
│ │ EventTotals │ │
|
||||||
|
│ └──────────────┘ │
|
||||||
|
│ ┌──────────────┐ (one row per target: running counts │
|
||||||
|
│ │ TargetTotals │ of its deliveries) │
|
||||||
|
│ └──────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1674,6 +1681,7 @@ status across potentially multiple attempts.
|
|||||||
| `event_id` | UUID | Foreign key → Event |
|
| `event_id` | UUID | Foreign key → Event |
|
||||||
| `target_id`| UUID | Foreign key → Target |
|
| `target_id`| UUID | Foreign key → Target |
|
||||||
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
||||||
|
| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) |
|
||||||
|
|
||||||
**Relations:** Belongs to Event. Belongs to Target. Has many
|
**Relations:** Belongs to Event. Belongs to Target. Has many
|
||||||
DeliveryResults.
|
DeliveryResults.
|
||||||
@@ -1741,33 +1749,70 @@ retries) is individually logged for full observability.
|
|||||||
|
|
||||||
**Relations:** Belongs to Delivery.
|
**Relations:** Belongs to Delivery.
|
||||||
|
|
||||||
|
#### EventTotals and TargetTotals
|
||||||
|
|
||||||
|
Running counts in each event database, read by the statistics pane at the
|
||||||
|
top of the webhook page. `EventTotals` is one row:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------------- | --------- | ----------- |
|
||||||
|
| `events` | integer | Events ever stored, resubmitted copies included |
|
||||||
|
| `events_removed` | integer | Events retention has deleted |
|
||||||
|
| `last_event_at` | timestamp | When the newest event arrived (nullable; empty before the first); retention leaves it as it is |
|
||||||
|
|
||||||
|
`TargetTotals` is one row per target, created by the first delivery to it:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| -------------------- | ------- | ----------- |
|
||||||
|
| `target_id` | UUID | The target (primary key) |
|
||||||
|
| `deliveries` | integer | Deliveries to it ever created, replays included |
|
||||||
|
| `delivered` | integer | Of those, how many became `delivered` |
|
||||||
|
| `failed` | integer | Of those, how many became `failed` |
|
||||||
|
| `deliveries_removed` | integer | Its deliveries retention has deleted |
|
||||||
|
| `failed_removed` | integer | Its failed deliveries retention has deleted |
|
||||||
|
|
||||||
|
Each count changes in the transaction that writes or deletes the rows it
|
||||||
|
counts. The pane's lifetime events are `events`, and its lifetime
|
||||||
|
deliveries and failures are `deliveries` and `failed` summed over the
|
||||||
|
targets; each figure within retention is the same less what retention
|
||||||
|
removed, so neither needs the rows themselves. Its last event is
|
||||||
|
`last_event_at`, written in the transaction that stores the event, so it
|
||||||
|
still shows once retention has removed every event. Its last-10-minutes and
|
||||||
|
last-24-hours figures are counted from the `events` and `deliveries`
|
||||||
|
indexes over just that window, the deliveries in one query grouped by
|
||||||
|
target. Its failure percentage for a window is the deliveries that became
|
||||||
|
`failed` in it out of all that became `delivered` or `failed` in it, and
|
||||||
|
a dash when none did.
|
||||||
|
|
||||||
#### Event-tier indexes
|
#### 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
|
||||||
tags, so `AutoMigrate` creates them on a fresh and on an existing database:
|
tags, so `AutoMigrate` creates them on a fresh database:
|
||||||
|
|
||||||
| Table | Columns | Serves |
|
| Table | Columns | Serves |
|
||||||
| ------------------ | --------------------------- | ------ |
|
| ------------------ | --------------------------- | ------ |
|
||||||
| `deliveries` | `status`, `deleted_at` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status |
|
| `deliveries` | `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 selects 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` | Retention, which selects expired events by age |
|
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
||||||
| `events` | `created_at` | Retention's delete of the expired events themselves |
|
| `events` | `created_at` | Retention, which selects expired events by age |
|
||||||
|
|
||||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention's
|
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
||||||
deletes leave it out, but their lookups of expired rows keep it. SQLite keeps
|
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
||||||
no statistics on these tables, and without them it rates the `deleted_at`
|
rates the `deleted_at` index, which every live row matches, above an index on
|
||||||
index, which every live row matches, above an index on a column matched
|
a column matched against several values or compared with a range. So every
|
||||||
against several values or compared with `<`. So every index but the last also
|
index but the last also covers `deleted_at`. It comes second, so that
|
||||||
covers `deleted_at`. It comes second, so that retention's deletes can use the
|
retention can use the index without it, except in `events`, where the
|
||||||
index without it, except in `events`, where `created_at` is compared with `<`
|
statistics compare `created_at` with a range (`>=`) and SQLite narrows by a
|
||||||
and SQLite narrows by a `<` only on the last column it uses.
|
range only on the last column it uses.
|
||||||
|
|
||||||
#### Common Fields
|
#### Common Fields
|
||||||
|
|
||||||
Every entity except `Setting` includes these fields from `BaseModel`.
|
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes
|
||||||
`Setting` is a bare key-value row with no `id`, no timestamps and no
|
these fields from `BaseModel`. `Setting` is a bare key-value row with no
|
||||||
soft delete:
|
`id`, no timestamps and no soft delete, and the two totals tables hold
|
||||||
|
counts, plus `last_event_at` in `event_totals`, keyed by a numeric `id`
|
||||||
|
and by `target_id`:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------ | --------- | ----------- |
|
| ------------ | --------- | ----------- |
|
||||||
@@ -1809,6 +1854,8 @@ encryption key is generated and stored, and an `admin` user is created.
|
|||||||
- **Events** — captured incoming webhook payloads
|
- **Events** — captured incoming webhook payloads
|
||||||
- **Deliveries** — event-to-target pairings and their status
|
- **Deliveries** — event-to-target pairings and their status
|
||||||
- **DeliveryResults** — individual delivery attempt logs
|
- **DeliveryResults** — individual delivery attempt logs
|
||||||
|
- **EventTotals** and **TargetTotals** — running counts of the above,
|
||||||
|
the deliveries per target, kept through retention
|
||||||
|
|
||||||
Per-webhook databases are created automatically when a webhook is
|
Per-webhook databases are created automatically when a webhook is
|
||||||
created (and lazily on first access for webhooks that predate this
|
created (and lazily on first access for webhooks that predate this
|
||||||
@@ -2788,6 +2835,7 @@ webhooker/
|
|||||||
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
||||||
|
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB)
|
||||||
│ │ ├── model_apikey.go # APIKey entity
|
│ │ ├── model_apikey.go # APIKey entity
|
||||||
│ │ ├── password.go # Argon2id hashing and verification
|
│ │ ├── password.go # Argon2id hashing and verification
|
||||||
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
||||||
@@ -2932,12 +2980,6 @@ 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/*`, `/sources`,
|
|
||||||
`/source/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is
|
|
||||||
set, its own **Sentry** error reporting. That Recoverer answers a panic
|
|
||||||
with the `500` error page in the normal layout; the global one keeps
|
|
||||||
the plain-text `500` for every other route.
|
|
||||||
|
|
||||||
Additionally, form endpoints (`/pages`, `/user/*`, `/sources`,
|
Additionally, form endpoints (`/pages`, `/user/*`, `/sources`,
|
||||||
`/source/*`) apply a **MaxBodySize** middleware that limits
|
`/source/*`) 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
|
||||||
@@ -3058,7 +3100,8 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
before the app starts; the image's health check; and `docker exec`,
|
before the app starts; the image's health check; and `docker exec`,
|
||||||
unless given `--user`
|
unless given `--user`
|
||||||
- GORM soft deletes on every entity that carries `BaseModel`, which is
|
- GORM soft deletes on every entity that carries `BaseModel`, which is
|
||||||
all of them but `Setting` (data preserved for audit)
|
all of them but `Setting`, `EventTotals` and `TargetTotals` (data
|
||||||
|
preserved for audit)
|
||||||
|
|
||||||
### Shutdown
|
### Shutdown
|
||||||
|
|
||||||
|
|||||||
@@ -93,11 +93,11 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
deliveries []database.Delivery
|
deliveries []database.Delivery
|
||||||
results []database.DeliveryResult
|
results []database.DeliveryResult
|
||||||
depths []struct{ Depth int }
|
depths []struct{ Depth int }
|
||||||
|
removed []database.TargetTotals
|
||||||
)
|
)
|
||||||
|
|
||||||
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
||||||
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
|
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
|
||||||
byAge := "idx_events_deleted_at_created_at (deleted_at=? AND created_at<?)"
|
|
||||||
|
|
||||||
// The delivery engine: recovery and the retry sweep, the sweep for
|
// The delivery engine: recovery and the retry sweep, the sweep for
|
||||||
// stranded pending deliveries, and the queue depth count.
|
// stranded pending deliveries, and the queue depth count.
|
||||||
@@ -123,25 +123,80 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
Order("attempt_num ASC").Find(&results),
|
Order("attempt_num ASC").Find(&results),
|
||||||
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
|
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
|
||||||
|
|
||||||
// Retention's three deletes (reapExpired), whose subqueries are built
|
// Retention (reapExpired, deleteEvents): one batch of expired
|
||||||
// afresh for each statement as it builds them.
|
// events, then their attempts, deliveries and the events.
|
||||||
expiredEventIDs := func() *gorm.DB {
|
var expired []string
|
||||||
return dry.Model(&database.Event{}).Select("id").
|
|
||||||
Where("created_at < ?", cutoff)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
assertPlanUses(t, db, dry.Unscoped().Model(&database.Event{}).
|
||||||
|
Where("created_at < ?", cutoff).
|
||||||
|
Limit(database.ExportReapBatchSize).Pluck("id", &expired),
|
||||||
|
"idx_events_created_at (created_at<?)")
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
"delivery_id IN (?)", dry.Model(&database.Delivery{}).
|
"delivery_id IN (?)", dry.Unscoped().Model(&database.Delivery{}).
|
||||||
Select("id").Where("event_id IN (?)", expiredEventIDs()),
|
Select("id").Where("event_id IN ?", ids),
|
||||||
).Delete(&database.DeliveryResult{}),
|
).Delete(&database.DeliveryResult{}),
|
||||||
"idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
|
"idx_delivery_results_delivery_id (delivery_id=?)",
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
"idx_deliveries_event_id (event_id=?)")
|
||||||
"event_id IN (?)", expiredEventIDs(),
|
assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}).
|
||||||
).Delete(&database.Delivery{}),
|
Select("target_id, count(*) AS deliveries_removed, "+
|
||||||
"idx_deliveries_event_id (event_id=?)", byAge)
|
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
database.DeliveryStatusFailed).
|
||||||
"created_at < ?", cutoff,
|
Where("event_id IN ?", ids).Group("target_id").Find(&removed),
|
||||||
).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
|
"idx_deliveries_event_id (event_id=?)")
|
||||||
|
assertPlanUses(t, db, dry.Unscoped().Where("event_id IN ?", ids).
|
||||||
|
Delete(&database.Delivery{}), "idx_deliveries_event_id (event_id=?)")
|
||||||
|
assertPlanUses(t, db, dry.Unscoped().Where("id IN ?", ids).
|
||||||
|
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
|
||||||
|
// page's statistics (readEventStats in the handlers): deliveries in
|
||||||
|
// progress, each target's deliveries finished since a time, which must
|
||||||
|
// come from the index alone, and events received since a time.
|
||||||
|
func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
db, err := mgr.GetDB(uuid.New().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dry := db.Session(&gorm.Session{DryRun: true})
|
||||||
|
since := time.Now()
|
||||||
|
|
||||||
|
var (
|
||||||
|
count int64
|
||||||
|
byTarget []struct{ TargetID string }
|
||||||
|
)
|
||||||
|
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
||||||
|
Where("status IN ?", []database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
}).Count(&count),
|
||||||
|
"idx_deliveries_status (status=? AND deleted_at=?)")
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
||||||
|
Select("target_id, "+
|
||||||
|
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
||||||
|
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
database.DeliveryStatusFailed).
|
||||||
|
Where("status IN ? AND finished_at >= ?",
|
||||||
|
[]database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
database.DeliveryStatusFailed,
|
||||||
|
}, since).
|
||||||
|
Group("target_id").Find(&byTarget),
|
||||||
|
"COVERING INDEX idx_deliveries_status "+
|
||||||
|
"(status=? AND deleted_at=? AND finished_at>?)")
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
||||||
|
Where("created_at >= ?", since).Count(&count),
|
||||||
|
"idx_events_deleted_at_created_at "+
|
||||||
|
"(deleted_at=? AND created_at>?)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -28,6 +29,10 @@ func NewTestRetentionReaper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportReapBatchSize exposes how many expired events one retention
|
||||||
|
// transaction deletes.
|
||||||
|
const ExportReapBatchSize = reapBatchSize
|
||||||
|
|
||||||
// ExportSweep runs a single retention sweep synchronously for tests.
|
// ExportSweep runs a single retention sweep synchronously for tests.
|
||||||
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
|
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
|
||||||
r.sweep(ctx)
|
r.sweep(ctx)
|
||||||
@@ -79,3 +84,14 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
|
|||||||
func DummyPasswordHashForTest() string {
|
func DummyPasswordHashForTest() string {
|
||||||
return dummyPasswordHash()
|
return dummyPasswordHash()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HashAtShippedCostForTest makes HashPassword hash at the shipped
|
||||||
|
// memory cost until t ends. t must not run in parallel with other
|
||||||
|
// tests, which would hash at that cost alongside it.
|
||||||
|
func HashAtShippedCostForTest(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hashAtShippedCostInTest = true
|
||||||
|
|
||||||
|
t.Cleanup(func() { hashAtShippedCostInTest = false })
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import "gorm.io/gorm"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
// DeliveryStatus represents the status of a delivery
|
// DeliveryStatus represents the status of a delivery
|
||||||
type DeliveryStatus string
|
type DeliveryStatus string
|
||||||
@@ -37,7 +41,7 @@ type Delivery struct {
|
|||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
|
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
|
||||||
TargetID string `gorm:"type:uuid;not null" json:"targetId"`
|
TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"`
|
||||||
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
|
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
|
||||||
|
|
||||||
// DeletedAt repeats the BaseModel field only to be the second column
|
// DeletedAt repeats the BaseModel field only to be the second column
|
||||||
@@ -45,6 +49,13 @@ type Delivery struct {
|
|||||||
// gives.
|
// gives.
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
|
||||||
|
|
||||||
|
// FinishedAt is when the delivery became delivered or failed, and
|
||||||
|
// nil while it is pending or retrying. It and then TargetID end the
|
||||||
|
// status index, so the webhook page counts each target's deliveries
|
||||||
|
// that finished in a recent window by reading just that window from
|
||||||
|
// the index.
|
||||||
|
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Event Event `json:"event,omitzero"`
|
Event Event `json:"event,omitzero"`
|
||||||
Target Target `json:"target,omitzero"`
|
Target Target `json:"target,omitzero"`
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The running totals in a webhook's event database keep the webhook
|
||||||
|
// page's lifetime figures right after retention has removed the rows
|
||||||
|
// they count, and let the page show them without counting every row.
|
||||||
|
// Each total changes in the transaction that writes or deletes the
|
||||||
|
// rows it counts.
|
||||||
|
|
||||||
|
// EventTotals is the single row counting a webhook's events: every
|
||||||
|
// event ever stored, how many of them retention has deleted, and when
|
||||||
|
// the newest arrived, which retention leaves as it is.
|
||||||
|
type EventTotals struct {
|
||||||
|
ID int64 `gorm:"primaryKey"`
|
||||||
|
|
||||||
|
Events int64 `gorm:"not null"`
|
||||||
|
EventsRemoved int64 `gorm:"not null"`
|
||||||
|
|
||||||
|
// LastEventAt is when the newest event arrived, or nil before the
|
||||||
|
// first.
|
||||||
|
LastEventAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName names the table AddEventTotals updates.
|
||||||
|
func (EventTotals) TableName() string {
|
||||||
|
return "event_totals"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetTotals is one row per target counting its deliveries: every
|
||||||
|
// delivery ever created, how many became delivered and how many
|
||||||
|
// failed, and how many deliveries and failed deliveries retention has
|
||||||
|
// deleted. The webhook's delivery figures are these rows summed.
|
||||||
|
type TargetTotals struct {
|
||||||
|
TargetID string `gorm:"type:uuid;primaryKey"`
|
||||||
|
|
||||||
|
Deliveries int64 `gorm:"not null"`
|
||||||
|
Delivered int64 `gorm:"not null"`
|
||||||
|
Failed int64 `gorm:"not null"`
|
||||||
|
|
||||||
|
DeliveriesRemoved int64 `gorm:"not null"`
|
||||||
|
FailedRemoved int64 `gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName names the table AddTargetTotals updates.
|
||||||
|
func (TargetTotals) TableName() string {
|
||||||
|
return "target_totals"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddEventTotals adds each count in add to the webhook's event totals,
|
||||||
|
// and records add.LastEventAt as when the newest event arrived if it is
|
||||||
|
// set. Call it on the transaction that writes or deletes the events it
|
||||||
|
// counts.
|
||||||
|
func AddEventTotals(tx *gorm.DB, add EventTotals) error {
|
||||||
|
err := tx.Exec(
|
||||||
|
`UPDATE event_totals SET
|
||||||
|
events = events + ?,
|
||||||
|
events_removed = events_removed + ?,
|
||||||
|
last_event_at = coalesce(?, last_event_at)`,
|
||||||
|
add.Events, add.EventsRemoved, add.LastEventAt,
|
||||||
|
).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("adding to event totals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTargetTotals adds each count in add to the totals of the target
|
||||||
|
// add.TargetID names, creating its row the first time. Call it on the
|
||||||
|
// transaction that writes or deletes the deliveries it counts.
|
||||||
|
func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
|
||||||
|
err := tx.Exec(
|
||||||
|
`INSERT INTO target_totals (target_id, deliveries, delivered,
|
||||||
|
failed, deliveries_removed, failed_removed)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (target_id) DO UPDATE SET
|
||||||
|
deliveries = deliveries + excluded.deliveries,
|
||||||
|
delivered = delivered + excluded.delivered,
|
||||||
|
failed = failed + excluded.failed,
|
||||||
|
deliveries_removed =
|
||||||
|
deliveries_removed + excluded.deliveries_removed,
|
||||||
|
failed_removed = failed_removed + excluded.failed_removed`,
|
||||||
|
add.TargetID, add.Deliveries, add.Delivered,
|
||||||
|
add.Failed, add.DeliveriesRemoved, add.FailedRemoved,
|
||||||
|
).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"adding to totals of target %s: %w", add.TargetID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@ package database
|
|||||||
|
|
||||||
// Migrate runs database migrations for the main application database.
|
// Migrate runs database migrations for the main application database.
|
||||||
// Only configuration-tier models are stored in the main database.
|
// Only configuration-tier models are stored in the main database.
|
||||||
// Event-tier models (Event, Delivery, DeliveryResult) live in
|
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
||||||
|
// TargetTotals) live in
|
||||||
// per-webhook dedicated databases managed by WebhookDBManager.
|
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||||
func (d *Database) Migrate() error {
|
func (d *Database) Migrate() error {
|
||||||
return d.db.AutoMigrate(
|
return d.db.AutoMigrate(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
)
|
)
|
||||||
@@ -63,10 +64,30 @@ func DefaultPasswordConfig() *PasswordConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashPassword generates an Argon2id hash of the password
|
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test
|
||||||
|
// binary hashes with: 1 MB instead of the shipped 64 MB. Every test
|
||||||
|
// that starts a database hashes the bootstrap admin password, dozens
|
||||||
|
// of them run in parallel, and under the race detector each 64 MB hash
|
||||||
|
// holds about 150 MB. VerifyPassword reads the cost from the hash it
|
||||||
|
// checks, so verification follows.
|
||||||
|
const testArgon2Memory = 1024
|
||||||
|
|
||||||
|
// hashAtShippedCostInTest makes a test binary hash at the shipped
|
||||||
|
// memory cost. Only TestHashPassword_ShippedParameters sets it.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // set by one test, see above
|
||||||
|
var hashAtShippedCostInTest bool
|
||||||
|
|
||||||
|
// HashPassword generates an Argon2id hash of the password. A binary
|
||||||
|
// built by go test hashes at testArgon2Memory; one built by go build
|
||||||
|
// always hashes at the defaults.
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
config := DefaultPasswordConfig()
|
config := DefaultPasswordConfig()
|
||||||
|
|
||||||
|
if testing.Testing() && !hashAtShippedCostInTest {
|
||||||
|
config.Memory = testArgon2Memory
|
||||||
|
}
|
||||||
|
|
||||||
// Generate a salt
|
// Generate a salt
|
||||||
salt := make([]byte, config.SaltLen)
|
salt := make([]byte, config.SaltLen)
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,39 @@ func TestHashPasswordUniqueness(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHashPassword_ShippedParameters hashes and verifies through
|
||||||
|
// HashPassword at the shipped Argon2id parameters. Every other test
|
||||||
|
// hashes at the lower memory cost a test binary uses, so this is the
|
||||||
|
// one that keeps production hashing covered. One hash and one
|
||||||
|
// verification: each costs 64 MB.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // changes the hashing cost for the whole binary
|
||||||
|
func TestHashPassword_ShippedParameters(t *testing.T) {
|
||||||
|
database.HashAtShippedCostForTest(t)
|
||||||
|
|
||||||
|
password := "correct horse battery staple"
|
||||||
|
|
||||||
|
hash, err := database.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hashing with the shipped parameters: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shipped = "$argon2id$v=19$m=65536,t=1,p=4$"
|
||||||
|
|
||||||
|
if !strings.HasPrefix(hash, shipped) {
|
||||||
|
t.Errorf("hash = %q, want prefix %q", hash, shipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := database.VerifyPassword(password, hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyPassword() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
t.Error("VerifyPassword() returned false for correct password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
||||||
// path. Login charges an unknown username a verification against a
|
// path. Login charges an unknown username a verification against a
|
||||||
// dummy hash so that a nonexistent account is not answered in
|
// dummy hash so that a nonexistent account is not answered in
|
||||||
|
|||||||
+117
-52
@@ -18,6 +18,19 @@ import (
|
|||||||
// computation.
|
// computation.
|
||||||
const hoursPerDay = 24
|
const hoursPerDay = 24
|
||||||
|
|
||||||
|
// reapBatchSize is how many expired events one retention transaction
|
||||||
|
// deletes. A transaction holds the event database's write lock, which
|
||||||
|
// the receiver and the delivery workers wait for, so a large prune is
|
||||||
|
// split into transactions each short enough to finish well inside the
|
||||||
|
// busy timeout.
|
||||||
|
const reapBatchSize = 1000
|
||||||
|
|
||||||
|
// reapBatchPause is how long retention waits after one batch before
|
||||||
|
// starting the next. A writer waiting for the write lock checks for it
|
||||||
|
// again after at most 100 ms, so a longer pause lets it in between two
|
||||||
|
// batches instead of only after the whole prune.
|
||||||
|
const reapBatchPause = 200 * time.Millisecond
|
||||||
|
|
||||||
// RetentionReaperParams holds the fx dependencies for the
|
// RetentionReaperParams holds the fx dependencies for the
|
||||||
// RetentionReaper.
|
// RetentionReaper.
|
||||||
type RetentionReaperParams struct {
|
type RetentionReaperParams struct {
|
||||||
@@ -187,13 +200,15 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
r.reapWebhook(wh.ID, wh.RetentionDays)
|
r.reapWebhook(ctx, wh.ID, wh.RetentionDays)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reapWebhook removes every expired event (and its dependents) from a
|
// reapWebhook removes every expired event (and its dependents) from a
|
||||||
// single webhook's database.
|
// single webhook's database, or as many as it reaches before ctx is
|
||||||
|
// cancelled.
|
||||||
func (r *RetentionReaper) reapWebhook(
|
func (r *RetentionReaper) reapWebhook(
|
||||||
|
ctx context.Context,
|
||||||
webhookID string,
|
webhookID string,
|
||||||
retentionDays int,
|
retentionDays int,
|
||||||
) {
|
) {
|
||||||
@@ -213,7 +228,7 @@ func (r *RetentionReaper) reapWebhook(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deleted, err := reapExpired(db, cutoff)
|
deleted, err := reapExpired(ctx, db, cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Error(
|
r.log.Error(
|
||||||
"retention sweep: failed to reap expired events",
|
"retention sweep: failed to reap expired events",
|
||||||
@@ -265,57 +280,107 @@ func retentionCutoff(
|
|||||||
), true
|
), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// reapExpired hard-deletes, in foreign-key-safe order, the delivery
|
// reapExpired hard-deletes the events older than cutoff, with their
|
||||||
// results, deliveries, and events associated with events older than
|
// deliveries and delivery results, reapBatchSize events per
|
||||||
// cutoff. Deletes are unscoped so rows are physically removed rather
|
// transaction with reapBatchPause between transactions, until none is
|
||||||
// than soft-deleted, reclaiming disk. It returns the number of events
|
// left. Once ctx is cancelled it returns after the batch in hand,
|
||||||
// deleted.
|
// leaving the rest to the next sweep, so stopping the app does not
|
||||||
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
// wait for a long prune. It returns the number of events deleted.
|
||||||
// Fresh subqueries are built per statement to avoid reusing a
|
func reapExpired(
|
||||||
// mutated builder across executions.
|
ctx context.Context, db *gorm.DB, cutoff time.Time,
|
||||||
expiredEventIDs := func() *gorm.DB {
|
) (int64, error) {
|
||||||
return db.Model(&Event{}).
|
var total int64
|
||||||
Select("id").
|
|
||||||
Where("created_at < ?", cutoff)
|
|
||||||
}
|
|
||||||
expiredDeliveryIDs := func() *gorm.DB {
|
|
||||||
return db.Model(&Delivery{}).
|
|
||||||
Select("id").
|
|
||||||
Where("event_id IN (?)", expiredEventIDs())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Delivery results whose delivery belongs to an expired event.
|
for {
|
||||||
res := db.Unscoped().
|
var eventIDs []string
|
||||||
Where("delivery_id IN (?)", expiredDeliveryIDs()).
|
|
||||||
Delete(&DeliveryResult{})
|
|
||||||
if res.Error != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"deleting expired delivery results: %w",
|
|
||||||
res.Error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Deliveries belonging to an expired event.
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
del := db.Unscoped().
|
err := tx.Unscoped().Model(&Event{}).
|
||||||
Where("event_id IN (?)", expiredEventIDs()).
|
Where("created_at < ?", cutoff).
|
||||||
Delete(&Delivery{})
|
Limit(reapBatchSize).
|
||||||
if del.Error != nil {
|
Pluck("id", &eventIDs).Error
|
||||||
return 0, fmt.Errorf(
|
if err != nil {
|
||||||
"deleting expired deliveries: %w",
|
return fmt.Errorf("selecting expired events: %w", err)
|
||||||
del.Error,
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. The expired events themselves.
|
if len(eventIDs) == 0 {
|
||||||
ev := db.Unscoped().
|
return nil
|
||||||
Where("created_at < ?", cutoff).
|
}
|
||||||
Delete(&Event{})
|
|
||||||
if ev.Error != nil {
|
|
||||||
return 0, fmt.Errorf(
|
|
||||||
"deleting expired events: %w",
|
|
||||||
ev.Error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ev.RowsAffected, nil
|
return deleteEvents(tx, eventIDs)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
total += int64(len(eventIDs))
|
||||||
|
|
||||||
|
if len(eventIDs) < reapBatchSize {
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return total, nil
|
||||||
|
case <-time.After(reapBatchPause):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteEvents hard-deletes the given events and, in foreign-key-safe
|
||||||
|
// order before them, their delivery results and deliveries, then adds
|
||||||
|
// what it deleted to the running totals. It runs on reapExpired's
|
||||||
|
// transaction, so the totals change exactly when the rows do. Deletes
|
||||||
|
// are unscoped so rows are physically removed rather than
|
||||||
|
// soft-deleted, reclaiming disk.
|
||||||
|
func deleteEvents(tx *gorm.DB, eventIDs []string) error {
|
||||||
|
// 1. The delivery results of the events' deliveries.
|
||||||
|
err := tx.Unscoped().
|
||||||
|
Where("delivery_id IN (?)", tx.Unscoped().Model(&Delivery{}).
|
||||||
|
Select("id").
|
||||||
|
Where("event_id IN ?", eventIDs)).
|
||||||
|
Delete(&DeliveryResult{}).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting expired delivery results: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The events' deliveries, after counting them, and the failed
|
||||||
|
// ones among them, per target. The status is tested in the select
|
||||||
|
// list rather than the WHERE clause: there, SQLite would read every
|
||||||
|
// failed delivery the webhook has through the status index,
|
||||||
|
// instead of only these through the event_id index.
|
||||||
|
var removed []TargetTotals
|
||||||
|
|
||||||
|
err = tx.Unscoped().Model(&Delivery{}).
|
||||||
|
Select("target_id, count(*) AS deliveries_removed, "+
|
||||||
|
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
|
||||||
|
DeliveryStatusFailed).
|
||||||
|
Where("event_id IN ?", eventIDs).
|
||||||
|
Group("target_id").
|
||||||
|
Find(&removed).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("counting expired deliveries: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Unscoped().
|
||||||
|
Where("event_id IN ?", eventIDs).
|
||||||
|
Delete(&Delivery{}).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting expired deliveries: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The events themselves.
|
||||||
|
ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{})
|
||||||
|
if ev.Error != nil {
|
||||||
|
return fmt.Errorf("deleting expired events: %w", ev.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range removed {
|
||||||
|
err = AddTargetTotals(tx, removed[i])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readEventTotals reads a webhook database's row of event totals,
|
||||||
|
// asserting that it has exactly one.
|
||||||
|
func readEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var rows []database.EventTotals
|
||||||
|
|
||||||
|
require.NoError(t, db.Find(&rows).Error)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// readTargetTotals reads a webhook database's target totals, keyed by
|
||||||
|
// target.
|
||||||
|
func readTargetTotals(
|
||||||
|
t *testing.T, db *gorm.DB,
|
||||||
|
) map[string]database.TargetTotals {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var rows []database.TargetTotals
|
||||||
|
|
||||||
|
require.NoError(t, db.Find(&rows).Error)
|
||||||
|
|
||||||
|
byTarget := make(map[string]database.TargetTotals, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
byTarget[row.TargetID] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
return byTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookDBManager_TotalsSurviveReopen verifies that a new event
|
||||||
|
// database starts with one row of zero event totals and no target
|
||||||
|
// totals, that adding to a target twice adds to the one row, and that
|
||||||
|
// opening the database again keeps everything added.
|
||||||
|
func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
|
||||||
|
db, err := mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fresh := readEventTotals(t, db)
|
||||||
|
assert.Equal(t, database.EventTotals{ID: fresh.ID}, fresh)
|
||||||
|
assert.Empty(t, readTargetTotals(t, db))
|
||||||
|
|
||||||
|
first, second := uuid.New().String(), uuid.New().String()
|
||||||
|
|
||||||
|
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
||||||
|
Events: 2,
|
||||||
|
}))
|
||||||
|
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||||
|
TargetID: first, Deliveries: 2, Delivered: 1,
|
||||||
|
}))
|
||||||
|
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||||
|
TargetID: first, Failed: 1,
|
||||||
|
}))
|
||||||
|
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||||
|
TargetID: second, Deliveries: 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Drop the cached connection so the next open reopens the file,
|
||||||
|
// as a restart would.
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
|
||||||
|
db, err = mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, database.EventTotals{ID: fresh.ID, Events: 2},
|
||||||
|
readEventTotals(t, db))
|
||||||
|
assert.Equal(t, map[string]database.TargetTotals{
|
||||||
|
first: {
|
||||||
|
TargetID: first, Deliveries: 2, Delivered: 1, Failed: 1,
|
||||||
|
},
|
||||||
|
second: {TargetID: second, Deliveries: 1},
|
||||||
|
}, readTargetTotals(t, db))
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedExpiredEvents stores count events created at the given time,
|
||||||
|
// each with a delivered delivery to one target and a failed delivery
|
||||||
|
// to the other, and one attempt for each delivery.
|
||||||
|
func seedExpiredEvents(
|
||||||
|
t *testing.T,
|
||||||
|
db *gorm.DB,
|
||||||
|
webhookID string,
|
||||||
|
count int,
|
||||||
|
createdAt time.Time,
|
||||||
|
delivered, failed string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
events := make([]database.Event, count)
|
||||||
|
deliveries := make([]database.Delivery, 0, 2*count)
|
||||||
|
|
||||||
|
for i := range events {
|
||||||
|
events[i] = database.Event{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
EntrypointID: uuid.New().String(),
|
||||||
|
Method: http.MethodPost,
|
||||||
|
}
|
||||||
|
events[i].ID = uuid.New().String()
|
||||||
|
events[i].CreatedAt = createdAt
|
||||||
|
|
||||||
|
deliveries = append(deliveries,
|
||||||
|
database.Delivery{
|
||||||
|
EventID: events[i].ID,
|
||||||
|
TargetID: delivered,
|
||||||
|
Status: database.DeliveryStatusDelivered,
|
||||||
|
},
|
||||||
|
database.Delivery{
|
||||||
|
EventID: events[i].ID,
|
||||||
|
TargetID: failed,
|
||||||
|
Status: database.DeliveryStatusFailed,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
||||||
|
require.NoError(t, db.CreateInBatches(deliveries, 500).Error)
|
||||||
|
|
||||||
|
results := make([]database.DeliveryResult, len(deliveries))
|
||||||
|
for i := range deliveries {
|
||||||
|
results[i] = database.DeliveryResult{
|
||||||
|
DeliveryID: deliveries[i].ID, AttemptNum: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, db.CreateInBatches(results, 500).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedBareEvents stores count events created at the given time, with
|
||||||
|
// no deliveries.
|
||||||
|
func seedBareEvents(
|
||||||
|
t *testing.T,
|
||||||
|
db *gorm.DB,
|
||||||
|
webhookID string,
|
||||||
|
count int,
|
||||||
|
createdAt time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
events := make([]database.Event, count)
|
||||||
|
for i := range events {
|
||||||
|
events[i] = database.Event{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
EntrypointID: uuid.New().String(),
|
||||||
|
Method: http.MethodPost,
|
||||||
|
}
|
||||||
|
events[i].CreatedAt = createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
|
||||||
|
// larger than one transaction's batch removes every expired event with
|
||||||
|
// its deliveries and delivery results, keeps the recent event, and
|
||||||
|
// adds what it removed to the event and target totals, so the totals
|
||||||
|
// within retention match the rows still stored.
|
||||||
|
func TestRetentionReaper_PrunesMoreThanOneBatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupRetentionTest(t)
|
||||||
|
|
||||||
|
webhookID := createWebhook(t, env.mainDB.DB(), 30)
|
||||||
|
|
||||||
|
db, err := env.mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expired := database.ExportReapBatchSize + 1
|
||||||
|
delivered, failed := uuid.New().String(), uuid.New().String()
|
||||||
|
seedExpiredEvents(t, db, webhookID, expired,
|
||||||
|
time.Now().Add(-40*24*time.Hour), delivered, failed)
|
||||||
|
|
||||||
|
// One recent event, delivered to the first target.
|
||||||
|
recent := seedEventChain(t, db, webhookID, time.Now())
|
||||||
|
require.NoError(t, db.Model(&database.Delivery{}).
|
||||||
|
Where("id = ?", recent.deliveryID).
|
||||||
|
Update("target_id", delivered).Error)
|
||||||
|
|
||||||
|
// The totals storing those rows would have left.
|
||||||
|
n := int64(expired)
|
||||||
|
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
||||||
|
Events: n + 1,
|
||||||
|
}))
|
||||||
|
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||||
|
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
|
||||||
|
}))
|
||||||
|
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||||
|
TargetID: failed, Deliveries: n, Failed: n,
|
||||||
|
}))
|
||||||
|
|
||||||
|
env.reaper.ExportSweep(context.Background())
|
||||||
|
|
||||||
|
// Only the recent event's rows are left.
|
||||||
|
for _, model := range []any{
|
||||||
|
&database.Event{}, &database.Delivery{}, &database.DeliveryResult{},
|
||||||
|
} {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
require.NoError(t, db.Model(model).Count(&count).Error)
|
||||||
|
assert.Equal(t, int64(1), count, "%T rows left", model)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertChainPresent(t, db, recent)
|
||||||
|
|
||||||
|
eventTotals := readEventTotals(t, db)
|
||||||
|
assert.Equal(t, database.EventTotals{
|
||||||
|
ID: eventTotals.ID, Events: n + 1, EventsRemoved: n,
|
||||||
|
}, eventTotals)
|
||||||
|
|
||||||
|
targetTotals := readTargetTotals(t, db)
|
||||||
|
assert.Equal(t, map[string]database.TargetTotals{
|
||||||
|
delivered: {
|
||||||
|
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
|
||||||
|
DeliveriesRemoved: n,
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
TargetID: failed, Deliveries: n, Failed: n,
|
||||||
|
DeliveriesRemoved: n, FailedRemoved: n,
|
||||||
|
},
|
||||||
|
}, targetTotals)
|
||||||
|
|
||||||
|
// A sweep with nothing left to remove changes nothing.
|
||||||
|
env.reaper.ExportSweep(context.Background())
|
||||||
|
|
||||||
|
assert.Equal(t, eventTotals, readEventTotals(t, db))
|
||||||
|
assert.Equal(t, targetTotals, readTargetTotals(t, db))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetentionReaper_WriteDuringPruneSucceeds verifies that a prune
|
||||||
|
// of several batches lets other writers in between its batches: an
|
||||||
|
// event stored once the first batch is deleted is stored while expired
|
||||||
|
// events are still left, not only after the prune has finished.
|
||||||
|
func TestRetentionReaper_WriteDuringPruneSucceeds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupRetentionTest(t)
|
||||||
|
|
||||||
|
webhookID := createWebhook(t, env.mainDB.DB(), 30)
|
||||||
|
|
||||||
|
db, err := env.mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Three batches of expired events, with nothing else stored: only
|
||||||
|
// the number of batches matters here.
|
||||||
|
expired := 3 * database.ExportReapBatchSize
|
||||||
|
seedBareEvents(t, db, webhookID, expired,
|
||||||
|
time.Now().Add(-40*24*time.Hour))
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-30 * 24 * time.Hour)
|
||||||
|
countExpired := func() int64 {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
require.NoError(t, db.Model(&database.Event{}).
|
||||||
|
Where("created_at < ?", cutoff).
|
||||||
|
Count(&count).Error)
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
pruned := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(pruned)
|
||||||
|
|
||||||
|
env.reaper.ExportSweep(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() { <-pruned })
|
||||||
|
|
||||||
|
// Every stored event is expired until the write below.
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
err := db.Model(&database.Event{}).Count(&count).Error
|
||||||
|
|
||||||
|
return err == nil && count < int64(expired)
|
||||||
|
}, 10*time.Second, 10*time.Millisecond)
|
||||||
|
|
||||||
|
event := &database.Event{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
EntrypointID: uuid.New().String(),
|
||||||
|
Method: http.MethodPost,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Create(event).Error)
|
||||||
|
|
||||||
|
assert.Positive(t, countExpired(),
|
||||||
|
"the event was stored only after the whole prune")
|
||||||
|
|
||||||
|
<-pruned
|
||||||
|
|
||||||
|
assert.Zero(t, countExpired())
|
||||||
|
|
||||||
|
var stored database.Event
|
||||||
|
|
||||||
|
require.NoError(t, db.First(&stored, "id = ?", event.ID).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRetentionReaper_StopDuringPruneLeavesTheRest verifies that
|
||||||
|
// stopping the reaper during a prune of several batches returns
|
||||||
|
// between two batches, well inside the stop timeout, leaving the
|
||||||
|
// remaining expired events for the next sweep, and that the totals
|
||||||
|
// match the rows left.
|
||||||
|
func TestRetentionReaper_StopDuringPruneLeavesTheRest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupRetentionTest(t)
|
||||||
|
|
||||||
|
webhookID := createWebhook(t, env.mainDB.DB(), 30)
|
||||||
|
|
||||||
|
db, err := env.mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Two batches and one more of expired events, a few of them with a
|
||||||
|
// delivered and a failed delivery for the target totals to count.
|
||||||
|
// Most carry nothing else, to keep the test quick.
|
||||||
|
const withDeliveries = 10
|
||||||
|
|
||||||
|
expiredAt := time.Now().Add(-40 * 24 * time.Hour)
|
||||||
|
delivered, failed := uuid.New().String(), uuid.New().String()
|
||||||
|
seedExpiredEvents(t, db, webhookID, withDeliveries, expiredAt,
|
||||||
|
delivered, failed)
|
||||||
|
seedBareEvents(t, db, webhookID,
|
||||||
|
2*database.ExportReapBatchSize+1-withDeliveries, expiredAt)
|
||||||
|
|
||||||
|
n := int64(2*database.ExportReapBatchSize + 1)
|
||||||
|
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
||||||
|
Events: n,
|
||||||
|
}))
|
||||||
|
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||||
|
TargetID: delivered, Deliveries: withDeliveries,
|
||||||
|
Delivered: withDeliveries,
|
||||||
|
}))
|
||||||
|
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
||||||
|
TargetID: failed, Deliveries: withDeliveries,
|
||||||
|
Failed: withDeliveries,
|
||||||
|
}))
|
||||||
|
|
||||||
|
env.reaper.ExportSetInterval(time.Millisecond)
|
||||||
|
env.reaper.ExportStart()
|
||||||
|
|
||||||
|
// Stop once the first batch is deleted. The stop lands in the pause
|
||||||
|
// after it, or at worst during the second batch, so at least the
|
||||||
|
// last event is left.
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
err := db.Model(&database.Event{}).Count(&count).Error
|
||||||
|
|
||||||
|
return err == nil && count < n
|
||||||
|
}, 10*time.Second, 10*time.Millisecond)
|
||||||
|
|
||||||
|
// The app's stop timeout.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
require.NoError(t, env.reaper.ExportStop(ctx))
|
||||||
|
|
||||||
|
var events int64
|
||||||
|
|
||||||
|
require.NoError(t, db.Model(&database.Event{}).Count(&events).Error)
|
||||||
|
assert.Positive(t, events, "the stop waited for the whole prune")
|
||||||
|
|
||||||
|
eventTotals := readEventTotals(t, db)
|
||||||
|
assert.Equal(t, events, eventTotals.Events-eventTotals.EventsRemoved)
|
||||||
|
|
||||||
|
targetTotals := readTargetTotals(t, db)
|
||||||
|
require.Len(t, targetTotals, 2)
|
||||||
|
|
||||||
|
for target, totals := range targetTotals {
|
||||||
|
var deliveries, failures int64
|
||||||
|
|
||||||
|
require.NoError(t, db.Model(&database.Delivery{}).
|
||||||
|
Where("target_id = ?", target).
|
||||||
|
Count(&deliveries).Error)
|
||||||
|
require.NoError(t, db.Model(&database.Delivery{}).
|
||||||
|
Where("target_id = ? AND status = ?",
|
||||||
|
target, database.DeliveryStatusFailed).
|
||||||
|
Count(&failures).Error)
|
||||||
|
|
||||||
|
assert.Equal(t, deliveries,
|
||||||
|
totals.Deliveries-totals.DeliveriesRemoved, target)
|
||||||
|
assert.Equal(t, failures, totals.Failed-totals.FailedRemoved,
|
||||||
|
target)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,8 @@ var errInvalidCachedDBType = errors.New(
|
|||||||
|
|
||||||
// WebhookDBManager manages per-webhook SQLite database files
|
// 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, and DeliveryResults.
|
// database containing Events, Deliveries, DeliveryResults and the
|
||||||
|
// running totals of them (EventTotals, TargetTotals).
|
||||||
// Database connections are opened lazily and cached.
|
// Database connections are opened lazily and cached.
|
||||||
type WebhookDBManager struct {
|
type WebhookDBManager struct {
|
||||||
dataDir string
|
dataDir string
|
||||||
@@ -295,6 +296,7 @@ func (m *WebhookDBManager) openDB(
|
|||||||
// Run migrations for event-tier models only
|
// Run migrations for event-tier models only
|
||||||
err = db.AutoMigrate(
|
err = db.AutoMigrate(
|
||||||
&Event{}, &Delivery{}, &DeliveryResult{},
|
&Event{}, &Delivery{}, &DeliveryResult{},
|
||||||
|
&EventTotals{}, &TargetTotals{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
@@ -305,6 +307,18 @@ func (m *WebhookDBManager) openDB(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A new database gets its row of event totals, all zero. Target
|
||||||
|
// totals rows are created by the first delivery to each target.
|
||||||
|
err = db.FirstOrCreate(&EventTotals{}).Error
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"creating event totals for webhook database %s: %w",
|
||||||
|
webhookID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
m.log.Info(
|
m.log.Info(
|
||||||
"opened per-webhook database",
|
"opened per-webhook database",
|
||||||
"webhook_id", webhookID,
|
"webhook_id", webhookID,
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// targetTotals reads one target's totals from a webhook database, all
|
||||||
|
// zero when it has no row.
|
||||||
|
func targetTotals(
|
||||||
|
t *testing.T, db *gorm.DB, targetID string,
|
||||||
|
) database.TargetTotals {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var rows []database.TargetTotals
|
||||||
|
|
||||||
|
require.NoError(t, db.Where("target_id = ?", targetID).
|
||||||
|
Find(&rows).Error)
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return database.TargetTotals{TargetID: targetID}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
|
||||||
|
// write records for the webhook page's statistics: the time a delivery
|
||||||
|
// finished, set only when it becomes delivered or failed, and one more
|
||||||
|
// on its target's delivered or failed total.
|
||||||
|
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
status database.DeliveryStatus
|
||||||
|
finished bool
|
||||||
|
delivered int64
|
||||||
|
failed int64
|
||||||
|
}{
|
||||||
|
{database.DeliveryStatusRetrying, false, 0, 0},
|
||||||
|
{database.DeliveryStatusDelivered, true, 1, 0},
|
||||||
|
{database.DeliveryStatusFailed, true, 0, 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.status), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := testWebhookDB(t)
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
event := seedEvent(t, db, `{}`)
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
d := seedDelivery(
|
||||||
|
t, db, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
|
||||||
|
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
||||||
|
db, &d, tt.status,
|
||||||
|
))
|
||||||
|
|
||||||
|
var stored database.Delivery
|
||||||
|
|
||||||
|
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
|
||||||
|
assert.Equal(t, tt.status, stored.Status)
|
||||||
|
|
||||||
|
if tt.finished {
|
||||||
|
require.NotNil(t, stored.FinishedAt)
|
||||||
|
assert.False(t, stored.FinishedAt.Before(before))
|
||||||
|
} else {
|
||||||
|
assert.Nil(t, stored.FinishedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, database.TargetTotals{
|
||||||
|
TargetID: targetID,
|
||||||
|
Delivered: tt.delivered,
|
||||||
|
Failed: tt.failed,
|
||||||
|
}, targetTotals(t, db, targetID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted covers a
|
||||||
|
// delivery retention deleted while the engine still held it. Failing
|
||||||
|
// it afterwards writes no row, so it adds no failure either: retention
|
||||||
|
// has already counted what it removed.
|
||||||
|
func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := testWebhookDB(t)
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
event := seedEvent(t, db, `{}`)
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
d := seedDelivery(
|
||||||
|
t, db, event.ID, targetID,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, db.Unscoped().
|
||||||
|
Delete(&database.Delivery{}, "id = ?", d.ID).Error)
|
||||||
|
|
||||||
|
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
||||||
|
db, &d, database.DeliveryStatusFailed,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert.Equal(t, database.TargetTotals{TargetID: targetID},
|
||||||
|
targetTotals(t, db, targetID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain covers a
|
||||||
|
// delivery settled a second time, as recovery can do when a worker has
|
||||||
|
// settled it since recovery read it. Neither status writes over the
|
||||||
|
// first, and the totals do not move.
|
||||||
|
func TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
finished := []database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
database.DeliveryStatusFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, first := range finished {
|
||||||
|
t.Run(string(first), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := testWebhookDB(t)
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
event := seedEvent(t, db, `{}`)
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
d := seedDelivery(
|
||||||
|
t, db, event.ID, targetID,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The delivery as recovery read it, before the worker
|
||||||
|
// settled it.
|
||||||
|
readBefore := d
|
||||||
|
|
||||||
|
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
||||||
|
db, &d, first,
|
||||||
|
))
|
||||||
|
|
||||||
|
var settled database.Delivery
|
||||||
|
|
||||||
|
require.NoError(t, db.First(&settled, "id = ?", d.ID).Error)
|
||||||
|
require.NotNil(t, settled.FinishedAt)
|
||||||
|
|
||||||
|
totals := targetTotals(t, db, targetID)
|
||||||
|
|
||||||
|
for _, again := range finished {
|
||||||
|
stale := readBefore
|
||||||
|
|
||||||
|
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
||||||
|
db, &stale, again,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored database.Delivery
|
||||||
|
|
||||||
|
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
|
||||||
|
assert.Equal(t, first, stored.Status)
|
||||||
|
require.NotNil(t, stored.FinishedAt)
|
||||||
|
assert.True(t, settled.FinishedAt.Equal(*stored.FinishedAt))
|
||||||
|
assert.Equal(t, totals, targetTotals(t, db, targetID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -531,6 +531,11 @@ func (e *Engine) processRetryTask(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set before anything below can fail the delivery: the failure is
|
||||||
|
// added to this target's totals.
|
||||||
|
d.EventID = task.EventID
|
||||||
|
d.TargetID = task.TargetID
|
||||||
|
|
||||||
if d.Status != database.DeliveryStatusRetrying {
|
if d.Status != database.DeliveryStatusRetrying {
|
||||||
e.log.Debug(
|
e.log.Debug(
|
||||||
"skipping retry for delivery "+
|
"skipping retry for delivery "+
|
||||||
@@ -562,8 +567,6 @@ func (e *Engine) processRetryTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
target := buildTargetFromTask(task)
|
target := buildTargetFromTask(task)
|
||||||
d.EventID = task.EventID
|
|
||||||
d.TargetID = task.TargetID
|
|
||||||
d.Event = event
|
d.Event = event
|
||||||
d.Target = target
|
d.Target = target
|
||||||
|
|
||||||
@@ -1554,8 +1557,9 @@ func (e *Engine) updateDeliveryStatus(
|
|||||||
targetType database.TargetType,
|
targetType database.TargetType,
|
||||||
status database.DeliveryStatus,
|
status database.DeliveryStatus,
|
||||||
) error {
|
) error {
|
||||||
err := webhookDB.Model(d).
|
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||||
Update("status", status).Error
|
return writeDeliveryStatus(tx, d, status)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"updating delivery %s to status %s: %w",
|
"updating delivery %s to status %s: %w",
|
||||||
@@ -1574,6 +1578,43 @@ func (e *Engine) updateDeliveryStatus(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeDeliveryStatus writes a delivery's new status. A delivery that
|
||||||
|
// becomes delivered or failed also gets the time it finished, and is
|
||||||
|
// added to its target's delivered or failed total. That write changes
|
||||||
|
// only a delivery not yet delivered or failed, and the total moves
|
||||||
|
// only when it changed a row: retention may have deleted the delivery
|
||||||
|
// while the engine was working on it, and a recovery path may settle
|
||||||
|
// a delivery that a worker has already settled.
|
||||||
|
func writeDeliveryStatus(
|
||||||
|
tx *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
) error {
|
||||||
|
if !status.Terminal() {
|
||||||
|
return tx.Model(d).Update("status", status).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
res := tx.Model(d).
|
||||||
|
Where("status NOT IN ?", []database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
database.DeliveryStatusFailed,
|
||||||
|
}).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"finished_at": time.Now(),
|
||||||
|
})
|
||||||
|
if res.Error != nil || res.RowsAffected == 0 {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
||||||
|
if status == database.DeliveryStatusFailed {
|
||||||
|
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.AddTargetTotals(tx, add)
|
||||||
|
}
|
||||||
|
|
||||||
// settleStatus moves a delivery to its outcome status and reports a
|
// settleStatus moves a delivery to its outcome status and reports a
|
||||||
// failed write through bookkeepingFailed, which leaves the row
|
// failed write through bookkeepingFailed, which leaves the row
|
||||||
// recoverable. It exists so the target call sites read as one
|
// recoverable. It exists so the target call sites read as one
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ func testWebhookDB(t *testing.T) *gorm.DB {
|
|||||||
&database.Event{},
|
&database.Event{},
|
||||||
&database.Delivery{},
|
&database.Delivery{},
|
||||||
&database.DeliveryResult{},
|
&database.DeliveryResult{},
|
||||||
|
&database.EventTotals{},
|
||||||
|
&database.TargetTotals{},
|
||||||
))
|
))
|
||||||
|
require.NoError(t, db.Create(&database.EventTotals{}).Error)
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,16 @@ func (e *Engine) ExportDeliverSlack(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no
|
||||||
|
// target type, so no metric moves.
|
||||||
|
func (e *Engine) ExportUpdateDeliveryStatus(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d *database.Delivery,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
) error {
|
||||||
|
return e.updateDeliveryStatus(webhookDB, d, "", status)
|
||||||
|
}
|
||||||
|
|
||||||
// ExportProcessNewTask exposes processNewTask.
|
// ExportProcessNewTask exposes processNewTask.
|
||||||
func (e *Engine) ExportProcessNewTask(
|
func (e *Engine) ExportProcessNewTask(
|
||||||
ctx context.Context, task *Task,
|
ctx context.Context, task *Task,
|
||||||
|
|||||||
@@ -418,6 +418,38 @@ func TestProcessRetryTask_TargetDeleted_MakesNoAttempt(
|
|||||||
assert.Zero(t, s.Engine.ExportInflightHeld())
|
assert.Zero(t, s.Engine.ExportInflightHeld())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget verifies
|
||||||
|
// that the failure of a retry abandoned because its target is gone is
|
||||||
|
// added to that target's own totals, not to a row with no target.
|
||||||
|
func TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
|
||||||
|
var hits atomic.Int64
|
||||||
|
|
||||||
|
task, targetID := tRetryChainSetup(
|
||||||
|
t, s, "gone-counted", &hits,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, s.MainDB.Delete(
|
||||||
|
&database.Target{}, "id = ?", targetID,
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
s.Engine.ExportProcessRetryTask(
|
||||||
|
context.Background(), &task,
|
||||||
|
)
|
||||||
|
|
||||||
|
var rows []database.TargetTotals
|
||||||
|
|
||||||
|
require.NoError(t, s.WebhookDB.Find(&rows).Error)
|
||||||
|
assert.Equal(t, []database.TargetTotals{
|
||||||
|
{TargetID: targetID, Failed: 1},
|
||||||
|
}, rows)
|
||||||
|
}
|
||||||
|
|
||||||
// TestProcessRetryTask_TargetPresent_StillDelivers is the guard's
|
// TestProcessRetryTask_TargetPresent_StillDelivers is the guard's
|
||||||
// mutation check: a liveness check that refused every retry would pass
|
// mutation check: a liveness check that refused every retry would pass
|
||||||
// the test above and break every retry there is.
|
// the test above and break every retry there is.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
|||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,11 @@ func (h *Handlers) authenticateUser(
|
|||||||
|
|
||||||
valid, err := database.VerifyPassword(password, user.Password)
|
valid, err := database.VerifyPassword(password, user.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to verify password", err)
|
h.log.Error("failed to verify password", "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
@@ -238,14 +242,24 @@ func (h *Handlers) createAuthenticatedSession(
|
|||||||
) error {
|
) error {
|
||||||
oldSess, err := h.session.Get(r)
|
oldSess, err := h.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get session", err)
|
h.log.Error("failed to get session", "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
sess, err := h.session.Regenerate(r, w, oldSess)
|
sess, err := h.session.Regenerate(r, w, oldSess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to regenerate session", err)
|
h.log.Error(
|
||||||
|
"failed to regenerate session", "error", err,
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -254,7 +268,11 @@ func (h *Handlers) createAuthenticatedSession(
|
|||||||
|
|
||||||
err = h.session.Save(r, w, sess)
|
err = h.session.Save(r, w, sess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to save session", err)
|
h.log.Error("failed to save session", "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,9 @@ func (h *Handlers) HandleDeliveryReplay() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -122,14 +124,14 @@ func (h *Handlers) replayDelivery(
|
|||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -171,7 +173,7 @@ func (h *Handlers) loadReplaySource(
|
|||||||
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
&original, "id = ?", chi.URLParam(r, "deliveryID"),
|
||||||
).Error
|
).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -193,7 +195,7 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to count in-flight deliveries", err,
|
w, "failed to count in-flight deliveries", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -210,7 +212,7 @@ func (h *Handlers) queueReplay(
|
|||||||
err = webhookDB.
|
err = webhookDB.
|
||||||
First(&event, "id = ?", original.EventID).Error
|
First(&event, "id = ?", original.EventID).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to load event for replay", err)
|
h.serverError(w, "failed to load event for replay", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -220,7 +222,7 @@ func (h *Handlers) queueReplay(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to create replay delivery", err,
|
w, "failed to create replay delivery", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -297,8 +299,9 @@ func countInFlightDeliveries(
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// createReplayDelivery writes the new pending delivery row and returns
|
// createReplayDelivery writes the new pending delivery row, adds it to
|
||||||
// the task that carries it to the delivery engine.
|
// its target's totals in the same transaction, and returns the task
|
||||||
|
// that carries it to the delivery engine.
|
||||||
//
|
//
|
||||||
// The row is written with associations omitted, and neither Event nor
|
// The row is written with associations omitted, and neither Event nor
|
||||||
// Target is populated on it: GORM's SaveBeforeAssociations would
|
// Target is populated on it: GORM's SaveBeforeAssociations would
|
||||||
@@ -317,7 +320,16 @@ func createReplayDelivery(
|
|||||||
Status: database.DeliveryStatusPending,
|
Status: database.DeliveryStatusPending,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := webhookDB.Omit(clause.Associations).Create(dlv).Error
|
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
err := tx.Omit(clause.Associations).Create(dlv).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.AddTargetTotals(tx, database.TargetTotals{
|
||||||
|
TargetID: dlv.TargetID, Deliveries: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return delivery.Task{}, err
|
return delivery.Task{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestErrorPage_RenderFailureKeepsStatus proves that an error page
|
|
||||||
// which cannot render answers with the status it was reporting, as
|
|
||||||
// plain text, and is not attempted again: a page whose own render
|
|
||||||
// fails reaches the error page, and the error page failing as well
|
|
||||||
// ends there with the 500.
|
|
||||||
func TestErrorPage_RenderFailureKeepsStatus(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
// .Status is an int, so asking it for a field fails the render.
|
|
||||||
failing := `{{.Status.Missing}}`
|
|
||||||
h.AddTemplateForTest("error.html", template.Must(
|
|
||||||
template.New("error").Parse(failing),
|
|
||||||
))
|
|
||||||
h.AddTemplateForTest("failing.html", template.Must(
|
|
||||||
template.New("failing").Parse(`{{.Data.Missing}}`),
|
|
||||||
))
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/", nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.HandleErrorPage(http.StatusNotFound).ServeHTTP(w, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
||||||
assert.Equal(t, "Not Found\n", w.Body.String())
|
|
||||||
|
|
||||||
w = httptest.NewRecorder()
|
|
||||||
h.RenderTemplateForTest(w, req, "failing.html", 0)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
|
||||||
}
|
|
||||||
@@ -52,7 +52,7 @@ func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
|||||||
// steered by a client.
|
// steered by a client.
|
||||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,21 +103,21 @@ func (h *Handlers) serveEventBody(
|
|||||||
eventID string,
|
eventID string,
|
||||||
) {
|
) {
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to read event body", err)
|
h.serverError(w, "failed to read event body", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func (h *Handlers) serveEventBody(
|
|||||||
// row and the whole body is served, or it does not and the
|
// row and the whole body is served, or it does not and the
|
||||||
// response is a clean 404.
|
// response is a clean 404.
|
||||||
if !found {
|
if !found {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func (h *Handlers) HandleEventResubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,20 +120,20 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// alphabet rather than from the request.
|
// alphabet rather than from the request.
|
||||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ func (h *Handlers) resubmitEvent(
|
|||||||
webhookDB, webhook.ID, eventID.String(),
|
webhookDB, webhook.ID, eventID.String(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to load event to resubmit", err)
|
h.serverError(w, "failed to load event to resubmit", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// A miss is a 404 whether the event was reaped, belongs to
|
// A miss is a 404 whether the event was reaped, belongs to
|
||||||
// another webhook, or never existed.
|
// another webhook, or never existed.
|
||||||
if !found {
|
if !found {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ func (h *Handlers) queueResubmit(
|
|||||||
// inactive one is skipped rather than refused.
|
// inactive one is skipped rather than refused.
|
||||||
targets, err := h.loadActiveTargets(webhook.ID)
|
targets, err := h.loadActiveTargets(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to query targets", err)
|
h.serverError(w, "failed to query targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ func (h *Handlers) queueResubmit(
|
|||||||
targets,
|
targets,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to store resubmitted event", err)
|
h.serverError(w, "failed to store resubmitted event", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,18 +65,33 @@ func (s *Handlers) LoadEventLogViewsForTest(
|
|||||||
page int,
|
page int,
|
||||||
) []EventLogView {
|
) []EventLogView {
|
||||||
views, _, _ := s.loadEventsWithDeliveries(
|
views, _, _ := s.loadEventsWithDeliveries(
|
||||||
w, newRequestForTest(), webhook, nil, page,
|
w, webhook, nil, page,
|
||||||
)
|
)
|
||||||
|
|
||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRequestForTest is the request the helpers here pass on for
|
// WebhookStatsForTest returns the figures the statistics pane on a
|
||||||
// callers that have none: it is used only to render the error page.
|
// webhook's page shows, from the webhook's entrypoints and targets
|
||||||
func newRequestForTest() *http.Request {
|
// loaded as that page loads them.
|
||||||
return httptest.NewRequestWithContext(
|
func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
|
||||||
context.Background(), http.MethodGet, "/", nil,
|
var entrypoints []database.Entrypoint
|
||||||
)
|
|
||||||
|
s.db.DB().Where("webhook_id = ?", webhookID).Find(&entrypoints)
|
||||||
|
|
||||||
|
var targets []database.Target
|
||||||
|
|
||||||
|
s.db.DB().Where("webhook_id = ?", webhookID).Find(&targets)
|
||||||
|
|
||||||
|
return s.loadWebhookStats(webhookID, entrypoints, targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishedByTargetForTest exposes finishedByTarget for use in the
|
||||||
|
// handlers_test package.
|
||||||
|
func FinishedByTargetForTest(
|
||||||
|
webhookDB *gorm.DB, since time.Time,
|
||||||
|
) ([]TargetFinished, error) {
|
||||||
|
return finishedByTarget(webhookDB, since)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddTemplateForTest registers a template under a page name so that
|
// AddTemplateForTest registers a template under a page name so that
|
||||||
@@ -132,5 +147,5 @@ func (s *Handlers) BuildDatabaseTargetConfigForTest(
|
|||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
expiry string,
|
expiry string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
|
return s.buildDatabaseTargetConfig(w, expiry)
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-101
@@ -91,18 +91,22 @@ 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, and navbar templates. The page file must be
|
// base, htmlheader, and navbar templates, and with any further files
|
||||||
// listed first so that its root action ({{template "base" .}})
|
// the page includes. The page file must be listed first so that its
|
||||||
// becomes the template set's entry point.
|
// root action ({{template "base" .}}) becomes the template set's entry
|
||||||
func parsePageTemplate(pageFile string) *template.Template {
|
// point.
|
||||||
|
func parsePageTemplate(
|
||||||
|
pageFile string, included ...string,
|
||||||
|
) *template.Template {
|
||||||
|
files := append([]string{
|
||||||
|
pageFile,
|
||||||
|
"base.html",
|
||||||
|
"htmlheader.html",
|
||||||
|
"navbar.html",
|
||||||
|
}, included...)
|
||||||
|
|
||||||
return template.Must(
|
return template.Must(
|
||||||
template.ParseFS(
|
template.ParseFS(templates.Templates, files...),
|
||||||
templates.Templates,
|
|
||||||
pageFile,
|
|
||||||
"base.html",
|
|
||||||
"htmlheader.html",
|
|
||||||
"navbar.html",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,11 +135,10 @@ func New(
|
|||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.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"),
|
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
"error.html": parsePageTemplate("error.html"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
@@ -147,16 +150,6 @@ func New(
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleErrorPage returns a handler that answers every request with
|
|
||||||
// the error page for status. The router uses it for unknown paths, the
|
|
||||||
// CSRF middleware for a refused form, and each admin page route
|
|
||||||
// group's recoverer for a panic.
|
|
||||||
func (s *Handlers) HandleErrorPage(status int) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.renderError(w, r, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Handlers) respondJSON(
|
func (s *Handlers) respondJSON(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
_ *http.Request,
|
_ *http.Request,
|
||||||
@@ -174,76 +167,15 @@ func (s *Handlers) respondJSON(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// serverError logs an error and answers with the 500 error page.
|
// serverError logs an error and sends a 500 response.
|
||||||
func (s *Handlers) serverError(
|
func (s *Handlers) serverError(
|
||||||
w http.ResponseWriter, r *http.Request, msg string, err error,
|
w http.ResponseWriter, msg string, err error,
|
||||||
) {
|
) {
|
||||||
s.log.Error(msg, "error", err)
|
s.log.Error(msg, "error", err)
|
||||||
s.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
}
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
// renderError answers with status and the error page: the normal
|
)
|
||||||
// layout, one fixed line explaining the status, and a link back to the
|
|
||||||
// webhook list, or to sign-in when nobody is signed in.
|
|
||||||
//
|
|
||||||
// It renders the page itself rather than through renderTemplate,
|
|
||||||
// whose own failure comes here. If the error page cannot render
|
|
||||||
// either, the answer is the same status in plain text: never a second
|
|
||||||
// attempt, and never a different status.
|
|
||||||
func (s *Handlers) renderError(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
status int,
|
|
||||||
) {
|
|
||||||
// The page names the signed-in user, and some error pages are
|
|
||||||
// served outside the routes where NoCache runs.
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
|
|
||||||
data := s.pageData(r, map[string]any{
|
|
||||||
"Status": status,
|
|
||||||
"StatusText": http.StatusText(status),
|
|
||||||
"Message": errorPageText(status),
|
|
||||||
})
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
err := s.templates["error.html"].Execute(&buf, data)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to render error page", "error", err)
|
|
||||||
http.Error(w, http.StatusText(status), status)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
|
|
||||||
_, err = buf.WriteTo(w)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error("failed to write error page", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// errorPageText is the line the error page shows for status. It is
|
|
||||||
// fixed per status, so the page tells the reader no more than the
|
|
||||||
// plain-text answers it replaced did.
|
|
||||||
func errorPageText(status int) string {
|
|
||||||
switch status {
|
|
||||||
case http.StatusBadRequest:
|
|
||||||
return "The request could not be read."
|
|
||||||
case http.StatusForbidden:
|
|
||||||
return "The request was refused. If it came from a form " +
|
|
||||||
"left open for a long time, reload the page and try " +
|
|
||||||
"again."
|
|
||||||
case http.StatusNotFound:
|
|
||||||
return "There is nothing here. It may have been deleted, " +
|
|
||||||
"or the address may be wrong."
|
|
||||||
case http.StatusServiceUnavailable:
|
|
||||||
return "The server is busy. Please try again in a moment."
|
|
||||||
default: // http.StatusInternalServerError
|
|
||||||
return "Something went wrong on the server. Please try " +
|
|
||||||
"again."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserInfo represents user information for templates
|
// UserInfo represents user information for templates
|
||||||
@@ -296,17 +228,14 @@ func (s *Handlers) renderTemplate(
|
|||||||
"template not found",
|
"template not found",
|
||||||
"template", pageTemplate,
|
"template", pageTemplate,
|
||||||
)
|
)
|
||||||
s.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.executeTemplate(w, r, tmpl, s.pageData(r, data))
|
|
||||||
}
|
|
||||||
|
|
||||||
// pageData adds the fields the shared layout renders to a page's own
|
|
||||||
// data.
|
|
||||||
func (s *Handlers) pageData(r *http.Request, data any) any {
|
|
||||||
userInfo := s.getUserInfo(r)
|
userInfo := s.getUserInfo(r)
|
||||||
csrfToken := middleware.CSRFToken(r)
|
csrfToken := middleware.CSRFToken(r)
|
||||||
|
|
||||||
@@ -320,16 +249,19 @@ func (s *Handlers) pageData(r *http.Request, data any) any {
|
|||||||
m["User"] = userInfo
|
m["User"] = userInfo
|
||||||
m["CSRFToken"] = csrfToken
|
m["CSRFToken"] = csrfToken
|
||||||
m["Version"] = version
|
m["Version"] = version
|
||||||
|
s.executeTemplate(w, tmpl, m)
|
||||||
|
|
||||||
return m
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return templateDataWrapper{
|
wrapper := templateDataWrapper{
|
||||||
User: userInfo,
|
User: userInfo,
|
||||||
CSRFToken: csrfToken,
|
CSRFToken: csrfToken,
|
||||||
Version: version,
|
Version: version,
|
||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.executeTemplate(w, tmpl, wrapper)
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeTemplate renders the template into a buffer and writes to
|
// executeTemplate renders the template into a buffer and writes to
|
||||||
@@ -342,7 +274,6 @@ func (s *Handlers) pageData(r *http.Request, data any) any {
|
|||||||
// this reason.
|
// this reason.
|
||||||
func (s *Handlers) executeTemplate(
|
func (s *Handlers) executeTemplate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
tmpl *template.Template,
|
tmpl *template.Template,
|
||||||
data any,
|
data any,
|
||||||
) {
|
) {
|
||||||
@@ -353,7 +284,10 @@ func (s *Handlers) executeTemplate(
|
|||||||
s.log.Error(
|
s.log.Error(
|
||||||
"failed to execute template", "error", err,
|
"failed to execute template", "error", err,
|
||||||
)
|
)
|
||||||
s.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,14 +307,10 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
|||||||
t, http.StatusInternalServerError, w.Code,
|
t, http.StatusInternalServerError, w.Code,
|
||||||
"a failed render must report a 500",
|
"a failed render must report a 500",
|
||||||
)
|
)
|
||||||
assert.NotContains(
|
assert.Equal(
|
||||||
t, w.Body.String(), partialPageMarker,
|
t, "Internal server error\n", w.Body.String(),
|
||||||
"the response must carry no part of the aborted page",
|
"the response must carry no part of the aborted page",
|
||||||
)
|
)
|
||||||
assert.Contains(
|
|
||||||
t, w.Body.String(), "500 Internal Server Error",
|
|
||||||
"a failed render must answer with the error page",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
@@ -36,14 +37,14 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
|||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
successMessage, errorMessage, handled := h.applyPasswordChange(
|
successMessage, errorMessage, handled := h.applyPasswordChange(
|
||||||
|
r.Context(),
|
||||||
w,
|
w,
|
||||||
r,
|
|
||||||
sessionUsername,
|
sessionUsername,
|
||||||
// PostFormValue, not FormValue: the credential must
|
// PostFormValue, not FormValue: the credential must
|
||||||
// come from the body, never from the query string.
|
// come from the body, never from the query string.
|
||||||
@@ -65,12 +66,12 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
|||||||
// applyPasswordChange verifies the current password and, on success,
|
// applyPasswordChange verifies the current password and, on success,
|
||||||
// persists a fresh hash for the user, reusing the same helpers that
|
// persists a fresh hash for the user, reusing the same helpers that
|
||||||
// bootstrap the admin user. It returns the success and error messages
|
// bootstrap the admin user. It returns the success and error messages
|
||||||
// to display on the profile page. On an internal failure it writes the
|
// to display on the profile page. On an internal failure it writes a
|
||||||
// error page itself and returns handled=false, signalling the caller
|
// 500 response itself and returns handled=false, signalling the caller
|
||||||
// to stop without re-rendering the page.
|
// to stop without re-rendering the page.
|
||||||
func (h *Handlers) applyPasswordChange(
|
func (h *Handlers) applyPasswordChange(
|
||||||
|
ctx context.Context,
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
username, currentPassword, newPassword, confirmPassword string,
|
username, currentPassword, newPassword, confirmPassword string,
|
||||||
) (string, string, bool) {
|
) (string, string, bool) {
|
||||||
// This endpoint verifies one password and hashes another, at
|
// This endpoint verifies one password and hashes another, at
|
||||||
@@ -78,10 +79,15 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
||||||
// this path outside it would leave a hole in it. The slot is held
|
// this path outside it would leave a hole in it. The slot is held
|
||||||
// across both hashes.
|
// across both hashes.
|
||||||
release, ok := h.mw.BeginPasswordVerification(r.Context())
|
release, ok := h.mw.BeginPasswordVerification(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Warn("password verification capacity exhausted")
|
h.log.Warn("password verification capacity exhausted")
|
||||||
h.renderError(w, r, http.StatusServiceUnavailable)
|
http.Error(
|
||||||
|
w,
|
||||||
|
"The server is busy verifying credentials. "+
|
||||||
|
"Please try again.",
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -97,7 +103,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
).First(&user).Error
|
).First(&user).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to load user for password change", err,
|
w, "failed to load user for password change", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
@@ -107,7 +113,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
currentPassword, user.Password,
|
currentPassword, user.Password,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to verify password", err)
|
h.serverError(w, "failed to verify password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -126,7 +132,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
|
|
||||||
hashedPassword, err := database.HashPassword(newPassword)
|
hashedPassword, err := database.HashPassword(newPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to hash new password", err)
|
h.serverError(w, "failed to hash new password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -135,7 +141,7 @@ func (h *Handlers) applyPasswordChange(
|
|||||||
"password", hashedPassword,
|
"password", hashedPassword,
|
||||||
).Error
|
).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to update password", err)
|
h.serverError(w, "failed to update password", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -156,7 +162,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
) (string, string, bool) {
|
) (string, string, bool) {
|
||||||
requestedUsername := chi.URLParam(r, "username")
|
requestedUsername := chi.URLParam(r, "username")
|
||||||
if requestedUsername == "" {
|
if requestedUsername == "" {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -166,7 +172,7 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
// unexpected retrieval error.
|
// unexpected retrieval error.
|
||||||
sess, err := h.session.Get(r)
|
sess, err := h.session.Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get session", err)
|
h.serverError(w, "failed to get session", err)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -174,7 +180,10 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
sessionUsername, ok := h.session.GetUsername(sess)
|
sessionUsername, ok := h.session.GetUsername(sess)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing username")
|
h.log.Error("authenticated session missing username")
|
||||||
h.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -182,14 +191,17 @@ func (h *Handlers) profileOwnerOrDeny(
|
|||||||
sessionUserID, ok := h.session.GetUserID(sess)
|
sessionUserID, ok := h.session.GetUserID(sess)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.log.Error("authenticated session missing user ID")
|
h.log.Error("authenticated session missing user ID")
|
||||||
h.renderError(w, r, http.StatusInternalServerError)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow users to act on their own profile.
|
// Only allow users to act on their own profile.
|
||||||
if requestedUsername != sessionUsername {
|
if requestedUsername != sessionUsername {
|
||||||
h.renderError(w, r, http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,9 +128,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
var sess *session.Session
|
var sess *session.Session
|
||||||
|
|
||||||
var h *handlers.Handlers
|
app := newTestApp(t, &log, &cfg, &sess)
|
||||||
|
|
||||||
app := newTestApp(t, &log, &cfg, &sess, &h)
|
|
||||||
app.RequireStart()
|
app.RequireStart()
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
@@ -141,7 +139,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Route("/user/{username}", func(r chi.Router) {
|
router.Route("/user/{username}", func(r chi.Router) {
|
||||||
r.Use(mw.CSRF(h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(mw.CSRF())
|
||||||
r.Use(mw.RequireAuth())
|
r.Use(mw.RequireAuth())
|
||||||
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
handlerReached = true
|
handlerReached = true
|
||||||
|
|||||||
@@ -149,7 +149,13 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
|
|||||||
"user_id = ?", userID,
|
"user_id = ?", userID,
|
||||||
).Order("created_at DESC").Find(&webhooks).Error
|
).Order("created_at DESC").Find(&webhooks).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to list webhooks", err)
|
h.log.Error(
|
||||||
|
"failed to list webhooks", "error", err,
|
||||||
|
)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -243,7 +249,9 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -303,7 +311,7 @@ func (h *Handlers) createWebhookWithEntrypoint(
|
|||||||
|
|
||||||
err := h.commitWebhook(webhook)
|
err := h.commitWebhook(webhook)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to create webhook", err)
|
h.serverError(w, "failed to create webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -380,7 +388,7 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -412,7 +420,7 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
if h.dbMgr.DBExists(webhook.ID) {
|
if h.dbMgr.DBExists(webhook.ID) {
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to get webhook database", err)
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -421,7 +429,7 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to load recent events", err)
|
h.serverError(w, "failed to load recent events", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -449,6 +457,7 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
"Targets": delivery.NewTargetViews(targets),
|
"Targets": delivery.NewTargetViews(targets),
|
||||||
"Events": events,
|
"Events": events,
|
||||||
"BaseURL": baseURL,
|
"BaseURL": baseURL,
|
||||||
|
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_detail.html", data)
|
h.renderTemplate(w, r, "source_detail.html", data)
|
||||||
@@ -474,7 +483,7 @@ func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -509,7 +518,7 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -518,7 +527,9 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -572,7 +583,7 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
|
|
||||||
err := h.db.DB().Save(webhook).Error
|
err := h.db.DB().Save(webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to update webhook", err)
|
h.serverError(w, "failed to update webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -602,7 +613,7 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -629,7 +640,7 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
// be removed by hand; deleted history cannot be recovered.
|
// be removed by hand; deleted history cannot be recovered.
|
||||||
err := h.commitWebhookDeletion(&webhook)
|
err := h.commitWebhookDeletion(&webhook)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to delete webhook", err)
|
h.serverError(w, "failed to delete webhook", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -655,7 +666,7 @@ func (h *Handlers) deleteWebhookResources(
|
|||||||
// 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.
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to delete webhook event database", err,
|
w, "failed to delete webhook event database", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -799,7 +810,7 @@ func (h *Handlers) ownedWebhook(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return database.Webhook{}, false
|
return database.Webhook{}, false
|
||||||
}
|
}
|
||||||
@@ -821,7 +832,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
// Without the map every delivery renders through a
|
// Without the map every delivery renders through a
|
||||||
// zero redactor, so failing the page is the only
|
// zero redactor, so failing the page is the only
|
||||||
// safe answer.
|
// safe answer.
|
||||||
h.serverError(w, r, "failed to load targets", err)
|
h.serverError(w, "failed to load targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -829,7 +840,7 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
|||||||
page := h.parsePage(r)
|
page := h.parsePage(r)
|
||||||
|
|
||||||
evts, total, ok := h.loadEventsWithDeliveries(
|
evts, total, ok := h.loadEventsWithDeliveries(
|
||||||
w, r, webhook, targets, page,
|
w, webhook, targets, page,
|
||||||
)
|
)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -939,7 +950,6 @@ func (h *Handlers) parsePage(r *http.Request) int {
|
|||||||
// caller must then render nothing further.
|
// caller must then render nothing further.
|
||||||
func (h *Handlers) loadEventsWithDeliveries(
|
func (h *Handlers) loadEventsWithDeliveries(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
targetMap map[string]eventLogTarget,
|
targetMap map[string]eventLogTarget,
|
||||||
page int,
|
page int,
|
||||||
@@ -953,7 +963,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to get webhook database", err,
|
w, "failed to get webhook database", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -990,7 +1000,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to load delivery attempts", err,
|
w, "failed to load delivery attempts", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -999,7 +1009,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(
|
h.serverError(
|
||||||
w, r, "failed to count event resubmissions", err,
|
w, "failed to count event resubmissions", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, 0, false
|
return nil, 0, false
|
||||||
@@ -1222,7 +1232,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1231,7 +1241,9 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1247,7 +1259,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
|
|
||||||
err = h.db.DB().Create(entrypoint).Error
|
err = h.db.DB().Create(entrypoint).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to create entrypoint", err)
|
h.serverError(w, "failed to create entrypoint", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1278,7 +1290,7 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1287,7 +1299,9 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1358,7 +1372,7 @@ func (h *Handlers) processTargetCreate(
|
|||||||
|
|
||||||
err = h.db.DB().Create(target).Error
|
err = h.db.DB().Create(target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to create target", err)
|
h.serverError(w, "failed to create target", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1452,7 +1466,7 @@ func (h *Handlers) buildTargetConfig(
|
|||||||
case database.TargetTypeSlack:
|
case database.TargetTypeSlack:
|
||||||
return h.buildSlackTargetConfig(w, r, in.URL)
|
return h.buildSlackTargetConfig(w, r, in.URL)
|
||||||
case database.TargetTypeDatabase:
|
case database.TargetTypeDatabase:
|
||||||
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
|
return h.buildDatabaseTargetConfig(w, in.Expiry)
|
||||||
case database.TargetTypeLog:
|
case database.TargetTypeLog:
|
||||||
return "", nil
|
return "", nil
|
||||||
default:
|
default:
|
||||||
@@ -1502,7 +1516,7 @@ func (h *Handlers) buildHTTPTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
|
return marshalTargetConfig(w, delivery.HTTPTargetConfig{
|
||||||
URL: in.URL,
|
URL: in.URL,
|
||||||
Headers: headers,
|
Headers: headers,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
@@ -1524,7 +1538,7 @@ func (h *Handlers) buildSlackTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
|
return marshalTargetConfig(w, delivery.SlackTargetConfig{
|
||||||
WebhookURL: targetURL,
|
WebhookURL: targetURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1578,14 +1592,16 @@ func (h *Handlers) validateTargetURL(
|
|||||||
|
|
||||||
// marshalTargetConfig serialises a target configuration for storage,
|
// marshalTargetConfig serialises a target configuration for storage,
|
||||||
// writing a 500 itself if it cannot.
|
// writing a 500 itself if it cannot.
|
||||||
func (h *Handlers) marshalTargetConfig(
|
func marshalTargetConfig(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
cfg any,
|
cfg any,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
configBytes, err := json.Marshal(cfg)
|
configBytes, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to encode target config", err)
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -1601,7 +1617,6 @@ func (h *Handlers) marshalTargetConfig(
|
|||||||
// expiry yields an empty config (the keep-forever default).
|
// expiry yields an empty config (the keep-forever default).
|
||||||
func (h *Handlers) buildDatabaseTargetConfig(
|
func (h *Handlers) buildDatabaseTargetConfig(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
|
||||||
expiry string,
|
expiry string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
expiry = strings.TrimSpace(expiry)
|
expiry = strings.TrimSpace(expiry)
|
||||||
@@ -1620,8 +1635,8 @@ func (h *Handlers) buildDatabaseTargetConfig(
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(
|
return marshalTargetConfig(
|
||||||
w, r, map[string]any{"expiry": expiry},
|
w, map[string]any{"expiry": expiry},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1675,7 +1690,7 @@ func (h *Handlers) deleteChildResource(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1685,7 +1700,11 @@ func (h *Handlers) deleteChildResource(
|
|||||||
childID, webhook.ID,
|
childID, webhook.ID,
|
||||||
).Delete(model)
|
).Delete(model)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
h.serverError(w, r, errMsg, result.Error)
|
h.log.Error(errMsg, "error", result.Error)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1775,14 +1794,18 @@ func (h *Handlers) toggleChildResource(
|
|||||||
"id = ? AND user_id = ?", sourceID, userID,
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
).First(&webhook).Error
|
).First(&webhook).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = toggleFn(webhook.ID, childID)
|
err = toggleFn(webhook.ID, childID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, errMsg, err)
|
h.log.Error(errMsg, "error", err)
|
||||||
|
http.Error(
|
||||||
|
w, "Internal server error",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ func (h *Handlers) HandleTargetEditSubmit() http.HandlerFunc {
|
|||||||
// middleware, which runs before CSRF parses the form.
|
// middleware, which runs before CSRF parses the form.
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusBadRequest)
|
http.Error(
|
||||||
|
w, "Bad request", http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -155,7 +157,7 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
|
|
||||||
err = h.db.DB().Save(target).Error
|
err = h.db.DB().Save(target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to update target", err)
|
h.serverError(w, "failed to update target", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -218,7 +220,7 @@ func (h *Handlers) ownedTarget(
|
|||||||
chi.URLParam(r, "targetID"), webhook.ID,
|
chi.URLParam(r, "targetID"), webhook.ID,
|
||||||
).First(&target).Error
|
).First(&target).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderError(w, r, http.StatusNotFound)
|
http.NotFound(w, r)
|
||||||
|
|
||||||
return database.Webhook{}, nil, false
|
return database.Webhook{}, nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ func (h *Handlers) processWebhookRequest(
|
|||||||
|
|
||||||
headersJSON, err := json.Marshal(r.Header)
|
headersJSON, err := json.Marshal(r.Header)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.receiverError(w, "failed to serialize headers", err)
|
h.serverError(w, "failed to serialize headers", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.receiverError(w, "failed to query targets", err)
|
h.serverError(w, "failed to query targets", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ func (h *Handlers) createAndDeliverEvent(
|
|||||||
targets,
|
targets,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.receiverError(w, "failed to store webhook event", err)
|
h.serverError(w, "failed to store webhook event", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -204,19 +204,6 @@ func (h *Handlers) createAndDeliverEvent(
|
|||||||
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// receiverError logs an error and answers the sender with a plain-text
|
|
||||||
// 500. The receiver's answers are for programs, so it never sends the
|
|
||||||
// error page the web UI uses.
|
|
||||||
func (h *Handlers) receiverError(
|
|
||||||
w http.ResponseWriter, msg string, err error,
|
|
||||||
) {
|
|
||||||
h.log.Error(msg, "error", err)
|
|
||||||
http.Error(
|
|
||||||
w, "Internal server error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// eventSource carries the fields a new event is built from. The
|
// eventSource carries the fields a new event is built from. The
|
||||||
// receiver fills it from the live request; the resubmit handler fills
|
// receiver fills it from the live request; the resubmit handler fills
|
||||||
// it from a stored event. Both then go through createAndFanOut, so an
|
// it from a stored event. Both then go through createAndFanOut, so an
|
||||||
@@ -266,11 +253,12 @@ func requestEventSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createAndFanOut writes the event and one pending delivery per target
|
// createAndFanOut writes the event and one pending delivery per target,
|
||||||
// in a single transaction, then hands the tasks to the delivery
|
// and adds them to the webhook's running totals, in a single
|
||||||
// engine. It is the only path by which an event and its deliveries are
|
// transaction, then hands the tasks to the delivery engine. It is the
|
||||||
// created, so a resubmitted event is retried, SSRF-guarded and
|
// only path by which an event and its deliveries are created, so a
|
||||||
// circuit-broken exactly as a received one is.
|
// resubmitted event is retried, SSRF-guarded and circuit-broken
|
||||||
|
// exactly as a received one is.
|
||||||
//
|
//
|
||||||
// The tasks are returned as well as queued, so a caller can report how
|
// 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.
|
||||||
@@ -310,6 +298,15 @@ func (h *Handlers) createAndFanOut(
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = database.AddEventTotals(tx, database.EventTotals{
|
||||||
|
Events: 1, LastEventAt: &event.CreatedAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
err = tx.Commit().Error
|
err = tx.Commit().Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf(
|
return nil, nil, fmt.Errorf(
|
||||||
@@ -368,8 +365,9 @@ func (h *Handlers) finishWebhookResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildDeliveryTasks creates one pending delivery per target in the
|
// buildDeliveryTasks creates one pending delivery per target in the
|
||||||
// transaction and returns the tasks for the delivery engine. The
|
// transaction, adds each to its target's totals, and returns the tasks
|
||||||
// caller owns the transaction and rolls it back on error.
|
// for the delivery engine. The caller owns the transaction and rolls
|
||||||
|
// it back on error.
|
||||||
func buildDeliveryTasks(
|
func buildDeliveryTasks(
|
||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
@@ -393,6 +391,13 @@ func buildDeliveryTasks(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = database.AddTargetTotals(tx, database.TargetTotals{
|
||||||
|
TargetID: targets[i].ID, Deliveries: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
tasks = append(tasks, delivery.Task{
|
tasks = append(tasks, delivery.Task{
|
||||||
DeliveryID: dlv.ID,
|
DeliveryID: dlv.ID,
|
||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The spans of the two recent windows the statistics pane reports on:
|
||||||
|
// the last 10 minutes and the last 24 hours.
|
||||||
|
const (
|
||||||
|
shortWindow = 10 * time.Minute
|
||||||
|
longWindow = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// percent turns a fraction into a percentage.
|
||||||
|
const percent = 100
|
||||||
|
|
||||||
|
// WebhookStats holds the figures in the statistics pane at the top of
|
||||||
|
// the webhook page.
|
||||||
|
type WebhookStats struct {
|
||||||
|
Entrypoints int
|
||||||
|
ActiveEntrypoints int
|
||||||
|
Targets int
|
||||||
|
ActiveTargets int
|
||||||
|
|
||||||
|
// Lifetime counts every event, delivery and failure the webhook
|
||||||
|
// has had, and WithinRetention those still stored.
|
||||||
|
Lifetime Counts
|
||||||
|
WithinRetention Counts
|
||||||
|
|
||||||
|
// InProgress counts the deliveries still pending or retrying.
|
||||||
|
InProgress int64
|
||||||
|
|
||||||
|
// LastEventAt is when the newest event arrived, or nil when none
|
||||||
|
// has. Retention does not change it.
|
||||||
|
LastEventAt *time.Time
|
||||||
|
|
||||||
|
Last10Minutes RecentWindow
|
||||||
|
Last24Hours RecentWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts holds a number of events, of deliveries and of failed
|
||||||
|
// deliveries.
|
||||||
|
type Counts struct {
|
||||||
|
Events int64
|
||||||
|
Deliveries int64
|
||||||
|
Failures int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentWindow holds what happened in one recent window: the events
|
||||||
|
// received in it, and the deliveries that became delivered or failed in
|
||||||
|
// it.
|
||||||
|
type RecentWindow struct {
|
||||||
|
Events int64
|
||||||
|
Delivered int64
|
||||||
|
Failed int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetFinished is how many of one target's deliveries became
|
||||||
|
// delivered, and how many failed, in a recent window.
|
||||||
|
type TargetFinished struct {
|
||||||
|
TargetID string
|
||||||
|
Delivered int64
|
||||||
|
Failed int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailurePercent is the share of the deliveries finished in the window
|
||||||
|
// that failed, or a dash when none finished. Deliveries still pending
|
||||||
|
// or retrying are not counted either way.
|
||||||
|
func (w RecentWindow) FailurePercent() string {
|
||||||
|
finished := w.Delivered + w.Failed
|
||||||
|
if finished == 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%.1f%%", percent*float64(w.Failed)/float64(finished),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadWebhookStats gathers the figures for the statistics pane from the
|
||||||
|
// webhook's entrypoints and targets, as the page has already loaded
|
||||||
|
// them, and from its event database. It returns nil, and logs why, when
|
||||||
|
// the event database cannot be read.
|
||||||
|
func (h *Handlers) loadWebhookStats(
|
||||||
|
webhookID string,
|
||||||
|
entrypoints []database.Entrypoint,
|
||||||
|
targets []database.Target,
|
||||||
|
) *WebhookStats {
|
||||||
|
stats := &WebhookStats{
|
||||||
|
Entrypoints: len(entrypoints),
|
||||||
|
Targets: len(targets),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range entrypoints {
|
||||||
|
if entrypoints[i].Active {
|
||||||
|
stats.ActiveEntrypoints++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range targets {
|
||||||
|
if targets[i].Active {
|
||||||
|
stats.ActiveTargets++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening an event database that does not exist would create it,
|
||||||
|
// and it would hold nothing to count.
|
||||||
|
if !h.dbMgr.DBExists(webhookID) {
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
||||||
|
if err == nil {
|
||||||
|
err = readEventStats(webhookDB, time.Now(), stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error(
|
||||||
|
"failed to read webhook statistics",
|
||||||
|
"webhook_id", webhookID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// readEventStats fills in the figures that come from the webhook's
|
||||||
|
// event database. None of them reads every stored row: the totals are
|
||||||
|
// one row for the events and one per target for the deliveries, and
|
||||||
|
// every other figure is read from an index, over only the rows it
|
||||||
|
// counts.
|
||||||
|
func readEventStats(
|
||||||
|
db *gorm.DB, now time.Time, stats *WebhookStats,
|
||||||
|
) error {
|
||||||
|
err := readTotals(db, stats)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Model(&database.Delivery{}).
|
||||||
|
Where("status IN ?", []database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
}).
|
||||||
|
Count(&stats.InProgress).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("counting deliveries in progress: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Last10Minutes, err = readRecentWindow(
|
||||||
|
db, now.Add(-shortWindow),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Last24Hours, err = readRecentWindow(
|
||||||
|
db, now.Add(-longWindow),
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readTotals fills in the lifetime and within-retention figures, and
|
||||||
|
// when the last event arrived, from the running totals: the events'
|
||||||
|
// row, and the targets' rows summed.
|
||||||
|
func readTotals(db *gorm.DB, stats *WebhookStats) error {
|
||||||
|
var events database.EventTotals
|
||||||
|
|
||||||
|
err := db.Take(&events).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading event totals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets []database.TargetTotals
|
||||||
|
|
||||||
|
err = db.Find(&targets).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading target totals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.Lifetime.Events = events.Events
|
||||||
|
stats.WithinRetention.Events = events.Events - events.EventsRemoved
|
||||||
|
stats.LastEventAt = events.LastEventAt
|
||||||
|
|
||||||
|
for _, t := range targets {
|
||||||
|
stats.Lifetime.Deliveries += t.Deliveries
|
||||||
|
stats.Lifetime.Failures += t.Failed
|
||||||
|
stats.WithinRetention.Deliveries += t.Deliveries - t.DeliveriesRemoved
|
||||||
|
stats.WithinRetention.Failures += t.Failed - t.FailedRemoved
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRecentWindow counts the events received, and the deliveries that
|
||||||
|
// became delivered or failed, since the given time.
|
||||||
|
func readRecentWindow(
|
||||||
|
db *gorm.DB, since time.Time,
|
||||||
|
) (RecentWindow, error) {
|
||||||
|
var w RecentWindow
|
||||||
|
|
||||||
|
err := db.Model(&database.Event{}).
|
||||||
|
Where("created_at >= ?", since).
|
||||||
|
Count(&w.Events).Error
|
||||||
|
if err != nil {
|
||||||
|
return w, fmt.Errorf("counting recent events: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
byTarget, err := finishedByTarget(db, since)
|
||||||
|
if err != nil {
|
||||||
|
return w, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range byTarget {
|
||||||
|
w.Delivered += f.Delivered
|
||||||
|
w.Failed += f.Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishedByTarget counts, for each target, the deliveries that became
|
||||||
|
// delivered and those that failed since the given time, in one query
|
||||||
|
// over just that window of the deliveries' status index. A target with
|
||||||
|
// neither is left out.
|
||||||
|
func finishedByTarget(
|
||||||
|
db *gorm.DB, since time.Time,
|
||||||
|
) ([]TargetFinished, error) {
|
||||||
|
var byTarget []TargetFinished
|
||||||
|
|
||||||
|
err := db.Model(&database.Delivery{}).
|
||||||
|
Select("target_id, "+
|
||||||
|
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
||||||
|
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
database.DeliveryStatusFailed).
|
||||||
|
Where("status IN ? AND finished_at >= ?",
|
||||||
|
[]database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
database.DeliveryStatusFailed,
|
||||||
|
}, since).
|
||||||
|
Group("target_id").
|
||||||
|
Find(&byTarget).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"counting deliveries finished by target: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return byTarget, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statsEntrypoint adds an entrypoint to a webhook and returns its path.
|
||||||
|
func statsEntrypoint(
|
||||||
|
t *testing.T, db *database.Database, webhookID string, active bool,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ep := &database.Entrypoint{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
Path: uuid.New().String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(ep).Error)
|
||||||
|
require.NoError(t, db.DB().Model(ep).Update("active", active).Error)
|
||||||
|
|
||||||
|
return ep.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsDelivery returns an event's delivery to a target.
|
||||||
|
func statsDelivery(
|
||||||
|
t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
|
||||||
|
) database.Delivery {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var d database.Delivery
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Where(
|
||||||
|
"event_id = ? AND target_id = ?", eventID, targetID,
|
||||||
|
).First(&d).Error)
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsFinish settles a delivery as the delivery engine does: its
|
||||||
|
// final status and the time it finished, and one more on its target's
|
||||||
|
// delivered or failed total, in one transaction.
|
||||||
|
func statsFinish(
|
||||||
|
t *testing.T,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
d database.Delivery,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
at time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
||||||
|
if status == database.DeliveryStatusFailed {
|
||||||
|
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
err := tx.Model(&database.Delivery{}).
|
||||||
|
Where("id = ?", d.ID).
|
||||||
|
Updates(map[string]any{"status": status, "finished_at": at}).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.AddTargetTotals(tx, add)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsAge moves an event's arrival back to the given time.
|
||||||
|
func statsAge(
|
||||||
|
t *testing.T, webhookDB *gorm.DB, eventID string, at time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Model(&database.Event{}).
|
||||||
|
Where("id = ?", eventID).
|
||||||
|
Update("created_at", at).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsTargetTotals reads a webhook database's target totals, keyed by
|
||||||
|
// target.
|
||||||
|
func statsTargetTotals(
|
||||||
|
t *testing.T, webhookDB *gorm.DB,
|
||||||
|
) map[string]database.TargetTotals {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var rows []database.TargetTotals
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Find(&rows).Error)
|
||||||
|
|
||||||
|
byTarget := make(map[string]database.TargetTotals, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
byTarget[row.TargetID] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
return byTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsHistory is the webhook seedStatsHistory builds: its event
|
||||||
|
// database, its newest event, and its two active targets.
|
||||||
|
type statsHistory struct {
|
||||||
|
webhook *database.Webhook
|
||||||
|
webhookDB *gorm.DB
|
||||||
|
newest database.Event
|
||||||
|
first, second string
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedStatsHistory builds the webhook the statistics test checks: 14
|
||||||
|
// days of retention, eleven entrypoints (one inactive) and five targets
|
||||||
|
// (three inactive). Nine events arrive through the receiver, and so
|
||||||
|
// each has a delivery to the two active targets. The oldest event is
|
||||||
|
// past retention, the next 30 hours old, the next six hours old, the
|
||||||
|
// other six just in. Five deliveries are settled as the delivery engine
|
||||||
|
// would, the rest left pending, and a replay adds a pending delivery to
|
||||||
|
// the oldest event. Once retention has removed the oldest event, every
|
||||||
|
// figure in the pane differs from every other.
|
||||||
|
func seedStatsHistory(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sess *session.Session,
|
||||||
|
db *database.Database,
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
) statsHistory {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID, Name: "stats", RetentionDays: 14,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||||
|
|
||||||
|
path := statsEntrypoint(t, db, wh.ID, true)
|
||||||
|
for range 9 {
|
||||||
|
statsEntrypoint(t, db, wh.ID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
statsEntrypoint(t, db, wh.ID, false)
|
||||||
|
|
||||||
|
first := seedConfiguredTarget(
|
||||||
|
t, db, wh.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+replayTargetURL+`"}`,
|
||||||
|
)
|
||||||
|
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||||
|
require.NoError(t, db.DB().Model(inactive).
|
||||||
|
Update("active", false).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := receiverRouter(h)
|
||||||
|
|
||||||
|
for range 9 {
|
||||||
|
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
events := listEvents(t, webhookDB)
|
||||||
|
require.Len(t, events, 9)
|
||||||
|
|
||||||
|
oldest, yesterday, middle, newest := events[0], events[1], events[2], events[8]
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
statsAge(t, webhookDB, oldest.ID, now.Add(-15*24*time.Hour))
|
||||||
|
statsAge(t, webhookDB, yesterday.ID, now.Add(-30*time.Hour))
|
||||||
|
statsAge(t, webhookDB, middle.ID, now.Add(-6*time.Hour))
|
||||||
|
|
||||||
|
oldestFailure := statsDelivery(t, webhookDB, oldest.ID, first.ID)
|
||||||
|
statsFinish(t, webhookDB, oldestFailure,
|
||||||
|
database.DeliveryStatusFailed, now.Add(-14*24*time.Hour))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, yesterday.ID, first.ID),
|
||||||
|
database.DeliveryStatusFailed, now.Add(-29*time.Hour))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, yesterday.ID, second.ID),
|
||||||
|
database.DeliveryStatusFailed, now.Add(-29*time.Hour))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, middle.ID, second.ID),
|
||||||
|
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
|
||||||
|
statsFinish(t, webhookDB,
|
||||||
|
statsDelivery(t, webhookDB, newest.ID, first.ID),
|
||||||
|
database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther,
|
||||||
|
postReplay(t, h, sess, wh.ID, oldestFailure.ID).Code)
|
||||||
|
|
||||||
|
return statsHistory{
|
||||||
|
webhook: wh,
|
||||||
|
webhookDB: webhookDB,
|
||||||
|
newest: newest,
|
||||||
|
first: first.ID,
|
||||||
|
second: second.ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsPrune runs the real retention reaper until it has removed one
|
||||||
|
// event from the webhook's database, then stops it.
|
||||||
|
func statsPrune(
|
||||||
|
t *testing.T,
|
||||||
|
db *database.Database,
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
log *logger.Logger,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
database.NewRetentionReaper(lc, database.RetentionReaperParams{
|
||||||
|
Config: &config.Config{
|
||||||
|
RetentionSweepInterval: 10 * time.Millisecond,
|
||||||
|
},
|
||||||
|
Database: db,
|
||||||
|
DBManager: dbMgr,
|
||||||
|
Logger: log,
|
||||||
|
})
|
||||||
|
|
||||||
|
lc.RequireStart()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
var totals database.EventTotals
|
||||||
|
|
||||||
|
err := webhookDB.Take(&totals).Error
|
||||||
|
|
||||||
|
return err == nil && totals.EventsRemoved == 1
|
||||||
|
}, 10*time.Second, 10*time.Millisecond)
|
||||||
|
|
||||||
|
lc.RequireStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsPane returns the text of the statistics pane in a rendered
|
||||||
|
// webhook page, everything from its heading to the next heading on the
|
||||||
|
// page, with the markup taken out and each run of space made one
|
||||||
|
// space. A table then reads header by header and row by row, each
|
||||||
|
// row's label followed by its figures in column order.
|
||||||
|
func statsPane(t *testing.T, page string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, pane, found := strings.Cut(page, ">Statistics</h2>")
|
||||||
|
require.True(t, found, "the page has no statistics pane")
|
||||||
|
|
||||||
|
pane, _, _ = strings.Cut(pane, "<h2")
|
||||||
|
pane = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(pane, " ")
|
||||||
|
|
||||||
|
return strings.Join(strings.Fields(pane), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertStatsTargets checks, for the history seedStatsHistory builds,
|
||||||
|
// each target's totals and its deliveries finished in the last 24
|
||||||
|
// hours. The first target has nine deliveries and the replay, the
|
||||||
|
// second nine; the inactive targets have none and so no row.
|
||||||
|
func assertStatsTargets(t *testing.T, hist statsHistory) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
first, second := hist.first, hist.second
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]database.TargetTotals{
|
||||||
|
first: {TargetID: first, Deliveries: 10, Delivered: 1, Failed: 2},
|
||||||
|
second: {TargetID: second, Deliveries: 9, Failed: 2},
|
||||||
|
}, statsTargetTotals(t, hist.webhookDB))
|
||||||
|
|
||||||
|
lastDay, err := handlers.FinishedByTargetForTest(
|
||||||
|
hist.webhookDB, time.Now().Add(-24*time.Hour),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.ElementsMatch(t, []handlers.TargetFinished{
|
||||||
|
{TargetID: first, Delivered: 1},
|
||||||
|
{TargetID: second, Failed: 1},
|
||||||
|
}, lastDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertStatsPaneAfterPrune checks the rendered statistics pane for the
|
||||||
|
// history seedStatsHistory builds, once retention has removed the
|
||||||
|
// oldest event: each figure after its label, in its column.
|
||||||
|
func assertStatsPaneAfterPrune(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sess *session.Session,
|
||||||
|
hist statsHistory,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
||||||
|
lastEvent := hist.newest.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
|
||||||
|
|
||||||
|
assert.Contains(t, pane, "Entrypoints 11 (10 active) "+
|
||||||
|
"Targets 5 (2 active) "+
|
||||||
|
"Deliveries in progress 12 "+
|
||||||
|
"Last event "+lastEvent+" "+
|
||||||
|
"Retention 14 days")
|
||||||
|
assert.Contains(t, pane, "Lifetime Within retention "+
|
||||||
|
"Events 9 8 "+
|
||||||
|
"Deliveries 19 16 "+
|
||||||
|
"Failures 4 3")
|
||||||
|
assert.Contains(t, pane, "Last 10 minutes Last 24 hours "+
|
||||||
|
"Events 6 7 "+
|
||||||
|
"Failures 0 1 "+
|
||||||
|
"Failure percentage 0.0% 50.0%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
|
||||||
|
// the statistics pane shows for the history seedStatsHistory builds,
|
||||||
|
// and each target's totals and recent figures, before and after the
|
||||||
|
// real retention reaper removes the oldest event.
|
||||||
|
func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
log *logger.Logger
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
hist := seedStatsHistory(t, h, sess, db, dbMgr)
|
||||||
|
first, second := hist.first, hist.second
|
||||||
|
|
||||||
|
stats := h.WebhookStatsForTest(hist.webhook.ID)
|
||||||
|
require.NotNil(t, stats)
|
||||||
|
|
||||||
|
assert.Equal(t, 11, stats.Entrypoints)
|
||||||
|
assert.Equal(t, 10, stats.ActiveEntrypoints)
|
||||||
|
assert.Equal(t, 5, stats.Targets)
|
||||||
|
assert.Equal(t, 2, stats.ActiveTargets)
|
||||||
|
assert.Equal(t, handlers.Counts{Events: 9, Deliveries: 19, Failures: 4},
|
||||||
|
stats.Lifetime)
|
||||||
|
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
|
||||||
|
assert.Equal(t, int64(14), stats.InProgress)
|
||||||
|
require.NotNil(t, stats.LastEventAt)
|
||||||
|
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt))
|
||||||
|
assert.Equal(t, handlers.RecentWindow{Events: 6, Delivered: 1},
|
||||||
|
stats.Last10Minutes)
|
||||||
|
assert.Equal(t, handlers.RecentWindow{
|
||||||
|
Events: 7, Delivered: 1, Failed: 1,
|
||||||
|
}, stats.Last24Hours)
|
||||||
|
assert.Equal(t, "0.0%", stats.Last10Minutes.FailurePercent())
|
||||||
|
assert.Equal(t, "50.0%", stats.Last24Hours.FailurePercent())
|
||||||
|
|
||||||
|
assertStatsTargets(t, hist)
|
||||||
|
|
||||||
|
// Retention removes the oldest event with its three deliveries:
|
||||||
|
// the first target's failed one and the pending replay, and the
|
||||||
|
// second target's pending one.
|
||||||
|
statsPrune(t, db, dbMgr, log, hist.webhookDB)
|
||||||
|
|
||||||
|
after := h.WebhookStatsForTest(hist.webhook.ID)
|
||||||
|
require.NotNil(t, after)
|
||||||
|
|
||||||
|
assert.Equal(t, stats.Lifetime, after.Lifetime)
|
||||||
|
assert.Equal(t, handlers.Counts{Events: 8, Deliveries: 16, Failures: 3},
|
||||||
|
after.WithinRetention)
|
||||||
|
assert.Equal(t, int64(12), after.InProgress)
|
||||||
|
assert.Equal(t, stats.LastEventAt, after.LastEventAt)
|
||||||
|
assert.Equal(t, stats.Last10Minutes, after.Last10Minutes)
|
||||||
|
assert.Equal(t, stats.Last24Hours, after.Last24Hours)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]database.TargetTotals{
|
||||||
|
first: {
|
||||||
|
TargetID: first, Deliveries: 10, Delivered: 1, Failed: 2,
|
||||||
|
DeliveriesRemoved: 2, FailedRemoved: 1,
|
||||||
|
},
|
||||||
|
second: {
|
||||||
|
TargetID: second, Deliveries: 9, Failed: 2,
|
||||||
|
DeliveriesRemoved: 1,
|
||||||
|
},
|
||||||
|
}, statsTargetTotals(t, hist.webhookDB))
|
||||||
|
|
||||||
|
assertStatsPaneAfterPrune(t, h, sess, hist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookStats_LastEventSurvivesPruningEveryEvent checks that once
|
||||||
|
// retention has removed every event, the pane still shows when the last
|
||||||
|
// one arrived rather than "none".
|
||||||
|
func TestWebhookStats_LastEventSurvivesPruningEveryEvent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
log *logger.Logger
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||||
|
|
||||||
|
path := statsEntrypoint(t, db, wh.ID, true)
|
||||||
|
require.Equal(t, http.StatusOK,
|
||||||
|
postReceiver(t, receiverRouter(h), path))
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
events := listEvents(t, webhookDB)
|
||||||
|
require.Len(t, events, 1)
|
||||||
|
|
||||||
|
arrived := events[0].CreatedAt
|
||||||
|
|
||||||
|
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
|
||||||
|
statsPrune(t, db, dbMgr, log, webhookDB)
|
||||||
|
require.Empty(t, listEvents(t, webhookDB))
|
||||||
|
|
||||||
|
stats := h.WebhookStatsForTest(wh.ID)
|
||||||
|
require.NotNil(t, stats)
|
||||||
|
require.NotNil(t, stats.LastEventAt)
|
||||||
|
assert.True(t, arrived.Equal(*stats.LastEventAt))
|
||||||
|
|
||||||
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
|
assert.Contains(t, pane,
|
||||||
|
"Last event "+arrived.UTC().Format("2006-01-02 15:04:05 UTC"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookStats_LastEventInUTC checks that the pane shows when the
|
||||||
|
// last event arrived in UTC, as the event list does, when the time was
|
||||||
|
// stored in another zone, as it is on a host whose local time is not
|
||||||
|
// UTC.
|
||||||
|
func TestWebhookStats_LastEventInUTC(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
arrived := time.Date(2026, time.March, 4, 22, 30, 0, 0,
|
||||||
|
time.FixedZone("EST", -5*60*60))
|
||||||
|
require.NoError(t, database.AddEventTotals(webhookDB,
|
||||||
|
database.EventTotals{Events: 1, LastEventAt: &arrived}))
|
||||||
|
|
||||||
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
|
assert.Contains(t, pane, "Last event 2026-03-05 03:30:00 UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
|
||||||
|
// pane itself, not only the line at the foot of the page, shows the
|
||||||
|
// webhook's retention period, for a finite one and for forever.
|
||||||
|
func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
retentionDays int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{30, "30 days"},
|
||||||
|
{database.RetentionForeverDays, "forever"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID,
|
||||||
|
Name: "retention",
|
||||||
|
RetentionDays: tt.retentionDays,
|
||||||
|
}
|
||||||
|
require.NoError(t,
|
||||||
|
db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||||
|
|
||||||
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
|
assert.Contains(t, pane, "Retention "+tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookStats_WebhookWithNoEvents covers a webhook whose event
|
||||||
|
// database has never been opened: every count is zero, the
|
||||||
|
// percentages are a dash, and showing the page does not create the
|
||||||
|
// database.
|
||||||
|
func TestWebhookStats_WebhookWithNoEvents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
|
||||||
|
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
|
||||||
|
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
|
||||||
|
|
||||||
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
|
assert.Contains(t, pane, "Last event none")
|
||||||
|
assert.Contains(t, pane, "Failure percentage — —")
|
||||||
|
assert.False(t, dbMgr.DBExists(wh.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecentWindow_FailurePercent pins the percentage: failed
|
||||||
|
// deliveries out of all that finished in the window.
|
||||||
|
func TestRecentWindow_FailurePercent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
window handlers.RecentWindow
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{handlers.RecentWindow{}, "—"},
|
||||||
|
{handlers.RecentWindow{Events: 4}, "—"},
|
||||||
|
{handlers.RecentWindow{Delivered: 3, Failed: 1}, "25.0%"},
|
||||||
|
{handlers.RecentWindow{Failed: 2}, "100.0%"},
|
||||||
|
{handlers.RecentWindow{Delivered: 2}, "0.0%"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ func CSRFToken(r *http.Request) string {
|
|||||||
// key to sign a CSRF cookie and validates a masked token submitted via
|
// key to sign a CSRF cookie and validates a masked token submitted via
|
||||||
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
// the "csrf_token" form field (or the "X-CSRF-Token" header) on
|
||||||
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
// POST/PUT/PATCH/DELETE requests. Requests with an invalid or missing
|
||||||
// token are logged and answered by forbidden, which must write the 403.
|
// token receive a 403 Forbidden response.
|
||||||
//
|
//
|
||||||
// The middleware detects the client-facing transport protocol
|
// The middleware detects the client-facing transport protocol
|
||||||
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
// per-request via reqtls.IsTLS, the single TLS predicate the session
|
||||||
@@ -36,9 +36,7 @@ func CSRFToken(r *http.Request) string {
|
|||||||
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
// Two gorilla/csrf instances are maintained — one with Secure cookies
|
||||||
// (for TLS) and one without (for plaintext HTTP) — because the
|
// (for TLS) and one without (for plaintext HTTP) — because the
|
||||||
// csrf.Secure option is set at creation time, not per-request.
|
// csrf.Secure option is set at creation time, not per-request.
|
||||||
func (m *Middleware) CSRF(
|
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||||
forbidden http.Handler,
|
|
||||||
) func(http.Handler) http.Handler {
|
|
||||||
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
csrfErrorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// CSRF is registered ahead of RequireAuth on every route
|
// CSRF is registered ahead of RequireAuth on every route
|
||||||
// group that uses it, so this WARN is reachable by an
|
// group that uses it, so this WARN is reachable by an
|
||||||
@@ -59,7 +57,7 @@ func (m *Middleware) CSRF(
|
|||||||
"remote_addr", r.RemoteAddr,
|
"remote_addr", r.RemoteAddr,
|
||||||
"reason", csrf.FailureReason(r),
|
"reason", csrf.FailureReason(r),
|
||||||
)
|
)
|
||||||
forbidden.ServeHTTP(w, r)
|
http.Error(w, "Forbidden - invalid CSRF token", http.StatusForbidden)
|
||||||
})
|
})
|
||||||
|
|
||||||
key := m.session.GetKey()
|
key := m.session.GetKey()
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ import (
|
|||||||
// csrfCookieName is the gorilla/csrf cookie name.
|
// csrfCookieName is the gorilla/csrf cookie name.
|
||||||
const csrfCookieName = "_gorilla_csrf"
|
const csrfCookieName = "_gorilla_csrf"
|
||||||
|
|
||||||
// forbidden stands in for the error page the server hands CSRF to
|
|
||||||
// answer a refused request with.
|
|
||||||
func forbidden(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
// csrfGetToken performs a GET request through the CSRF middleware
|
// csrfGetToken performs a GET request through the CSRF middleware
|
||||||
// and returns the token and cookies.
|
// and returns the token and cookies.
|
||||||
func csrfGetToken(
|
func csrfGetToken(
|
||||||
@@ -104,7 +98,7 @@ func TestCSRF_GETSetsToken(t *testing.T) {
|
|||||||
|
|
||||||
var gotToken string
|
var gotToken string
|
||||||
|
|
||||||
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
handler := m.CSRF()(http.HandlerFunc(
|
||||||
func(_ http.ResponseWriter, r *http.Request) {
|
func(_ http.ResponseWriter, r *http.Request) {
|
||||||
gotToken = middleware.CSRFToken(r)
|
gotToken = middleware.CSRFToken(r)
|
||||||
},
|
},
|
||||||
@@ -126,7 +120,7 @@ func TestCSRF_POSTWithValidToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -158,7 +152,7 @@ func csrfPOSTWithoutTokenTest(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, env)
|
m, _ := testMiddleware(t, env)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
// GET to establish the CSRF cookie
|
// GET to establish the CSRF cookie
|
||||||
getHandler := csrfMW(http.HandlerFunc(
|
getHandler := csrfMW(http.HandlerFunc(
|
||||||
@@ -215,7 +209,7 @@ func TestCSRF_POSTWithInvalidToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
// GET to establish the CSRF cookie
|
// GET to establish the CSRF cookie
|
||||||
getHandler := csrfMW(http.HandlerFunc(
|
getHandler := csrfMW(http.HandlerFunc(
|
||||||
@@ -271,7 +265,7 @@ func TestCSRF_GETDoesNotValidate(t *testing.T) {
|
|||||||
|
|
||||||
var called bool
|
var called bool
|
||||||
|
|
||||||
handler := m.CSRF(http.HandlerFunc(forbidden))(http.HandlerFunc(
|
handler := m.CSRF()(http.HandlerFunc(
|
||||||
func(_ http.ResponseWriter, _ *http.Request) {
|
func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
called = true
|
called = true
|
||||||
},
|
},
|
||||||
@@ -334,7 +328,7 @@ func csrfTookStrictPath(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, env)
|
m, _ := testMiddleware(t, env)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
newReq := func(method string) *http.Request {
|
newReq := func(method string) *http.Request {
|
||||||
r := httptest.NewRequestWithContext(
|
r := httptest.NewRequestWithContext(
|
||||||
@@ -483,7 +477,7 @@ func TestCSRF_ProdMode_PlaintextHTTP_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -523,7 +517,7 @@ func TestCSRF_ProdMode_BehindProxy_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
@@ -568,7 +562,7 @@ func TestCSRF_ProdMode_DirectTLS_POSTWithValidToken(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentProd)
|
m, _ := testMiddleware(t, config.EnvironmentProd)
|
||||||
csrfMW := m.CSRF(http.HandlerFunc(forbidden))
|
csrfMW := m.CSRF()
|
||||||
|
|
||||||
getReq := httptest.NewRequestWithContext(
|
getReq := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
|
|||||||
@@ -260,9 +260,7 @@ func logSites() map[string]logSite {
|
|||||||
) http.Handler {
|
) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return m.CSRF(http.HandlerFunc(forbidden))(
|
return m.CSRF()(unreachable(t))
|
||||||
unreachable(t),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
send: postNoToken,
|
send: postNoToken,
|
||||||
wantStatus: http.StatusForbidden,
|
wantStatus: http.StatusForbidden,
|
||||||
|
|||||||
@@ -109,8 +109,7 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
|
|
||||||
// Recoverer returns middleware that turns a handler panic into one
|
// Recoverer returns middleware that turns a handler panic into one
|
||||||
// structured ERROR record and a 500, rather than a dropped
|
// structured ERROR record and a 500, rather than a dropped
|
||||||
// connection. The 500 is page when page is not nil, and plain text
|
// connection.
|
||||||
// when it is nil or when page panics before writing anything.
|
|
||||||
//
|
//
|
||||||
// It replaces chi's middleware.Recoverer, which does neither on a
|
// It replaces chi's middleware.Recoverer, which does neither on a
|
||||||
// current Go release. chi v1.5.5's pretty-printer scans the stack for
|
// current Go release. chi v1.5.5's pretty-printer scans the stack for
|
||||||
@@ -137,13 +136,9 @@ func (w *recoverResponseWriter) Unwrap() http.ResponseWriter {
|
|||||||
//
|
//
|
||||||
// Unlike http.Error on its own, it deletes any Set-Cookie the handler
|
// Unlike http.Error on its own, it deletes any Set-Cookie the handler
|
||||||
// set before panicking, because a request that failed must not hand
|
// set before panicking, because a request that failed must not hand
|
||||||
// the client a credential. It touches no other header: when page
|
// the client a credential; every other header is left to http.Error.
|
||||||
// answers, every other header the handler set goes out with it, apart
|
|
||||||
// from any page sets itself; otherwise they are left to http.Error.
|
|
||||||
// See https://git.eeqj.de/sneak/webhooker/issues/193.
|
// See https://git.eeqj.de/sneak/webhooker/issues/193.
|
||||||
func (s *Middleware) Recoverer(
|
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||||
page http.Handler,
|
|
||||||
) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(
|
return http.HandlerFunc(func(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
@@ -176,14 +171,6 @@ func (s *Middleware) Recoverer(
|
|||||||
|
|
||||||
rw.Header().Del("Set-Cookie")
|
rw.Header().Del("Set-Cookie")
|
||||||
|
|
||||||
if page != nil {
|
|
||||||
s.servePage(rw, r, page)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rw.committed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Error(
|
http.Error(
|
||||||
rw,
|
rw,
|
||||||
http.StatusText(
|
http.StatusText(
|
||||||
@@ -198,27 +185,6 @@ func (s *Middleware) Recoverer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// servePage answers with page. A panic in page itself is logged and
|
|
||||||
// recovered here, so the Recoverer can still send its plain 500.
|
|
||||||
func (s *Middleware) servePage(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
page http.Handler,
|
|
||||||
) {
|
|
||||||
defer func() {
|
|
||||||
rvr := recover()
|
|
||||||
if rvr != nil {
|
|
||||||
s.log.Error("error page panic",
|
|
||||||
"panic", logfield.Truncate(
|
|
||||||
fmt.Sprint(rvr), maxPanicValueBytes,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
page.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// logPanic writes the record. Every field it can grow is truncated to
|
// logPanic writes the record. Every field it can grow is truncated to
|
||||||
// a fixed budget, so MaxPanicLogLineBytes holds.
|
// a fixed budget, so MaxPanicLogLineBytes holds.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func newRecovererProbe(
|
|||||||
// Logging outside so the recovered 500 is the status it records.
|
// Logging outside so the recovered 500 is the status it records.
|
||||||
router.Use(chimw.RequestID)
|
router.Use(chimw.RequestID)
|
||||||
router.Use(m.Logging())
|
router.Use(m.Logging())
|
||||||
router.Use(m.Recoverer(nil))
|
router.Use(m.Recoverer())
|
||||||
router.Get("/probe", handler)
|
router.Get("/probe", handler)
|
||||||
|
|
||||||
serverErrors := new(bytes.Buffer)
|
serverErrors := new(bytes.Buffer)
|
||||||
@@ -637,7 +637,7 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
|||||||
|
|
||||||
m, _ := capturingMiddleware(t)
|
m, _ := capturingMiddleware(t)
|
||||||
|
|
||||||
handler := m.Recoverer(nil)(http.HandlerFunc(
|
handler := m.Recoverer()(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = w.Write([]byte("chunk"))
|
_, _ = w.Write([]byte("chunk"))
|
||||||
|
|
||||||
@@ -672,59 +672,3 @@ func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Equal(t, "chunk", string(body))
|
assert.Equal(t, "chunk", string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRecovererAnswersWithThePage covers a recoverer given a page:
|
|
||||||
// the panic is logged as before, and the 500 is that page.
|
|
||||||
func TestRecovererAnswersWithThePage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m, logs := capturingMiddleware(t)
|
|
||||||
|
|
||||||
page := http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
_, _ = w.Write([]byte("the error page"))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
|
||||||
w, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, "/", nil,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Equal(t, "the error page", w.Body.String())
|
|
||||||
assert.Contains(t, logs.String(), `"msg":"handler panic"`)
|
|
||||||
assert.Contains(t, logs.String(), panicMarker)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRecovererFallsBackWhenThePagePanics covers a page that panics
|
|
||||||
// before writing anything: both panics are logged, and the client
|
|
||||||
// still gets the plain 500.
|
|
||||||
func TestRecovererFallsBackWhenThePagePanics(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m, logs := capturingMiddleware(t)
|
|
||||||
|
|
||||||
const pagePanic = "QQERRORPAGEPANICQQ"
|
|
||||||
|
|
||||||
page := http.HandlerFunc(
|
|
||||||
func(http.ResponseWriter, *http.Request) {
|
|
||||||
panic(pagePanic)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
m.Recoverer(page)(http.HandlerFunc(panicProbe)).ServeHTTP(
|
|
||||||
w, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, "/", nil,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
|
||||||
assert.Contains(t, logs.String(), panicMarker)
|
|
||||||
assert.Contains(t, logs.String(), pagePanic)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ func (n *noopEvictor) EvictWebhook(string) {}
|
|||||||
// and the database, exactly as internal/handlers builds them.
|
// and the database, exactly as internal/handlers builds them.
|
||||||
//
|
//
|
||||||
// One application per test function, not per case: every start that
|
// One application per test function, not per case: every start that
|
||||||
// finds no account seeds one at 64 MB of Argon2id, and this package's
|
// finds no account seeds one with an Argon2id hash, and this package's
|
||||||
// budget is not the place to spend that repeatedly.
|
// budget is not the place to spend that repeatedly.
|
||||||
func newServerApp(
|
func newServerApp(
|
||||||
t *testing.T, dir string,
|
t *testing.T, dir string,
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
package server_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The link back the error page offers: to the webhook list for a
|
|
||||||
// signed-in user, to sign-in for anyone else.
|
|
||||||
const (
|
|
||||||
backToWebhooks = `<a href="/sources" class="btn-secondary">` +
|
|
||||||
`Back to webhooks</a>`
|
|
||||||
backToSignIn = `<a href="/pages/login" class="btn-primary">` +
|
|
||||||
`Sign in</a>`
|
|
||||||
)
|
|
||||||
|
|
||||||
// assertErrorPage checks that w is the error page for status, in the
|
|
||||||
// normal layout, offering link.
|
|
||||||
func assertErrorPage(
|
|
||||||
t *testing.T,
|
|
||||||
w *httptest.ResponseRecorder,
|
|
||||||
status int,
|
|
||||||
link string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
body := w.Body.String()
|
|
||||||
|
|
||||||
assert.Equal(t, status, w.Code)
|
|
||||||
assert.Equal(
|
|
||||||
t, "text/html; charset=utf-8", w.Header().Get("Content-Type"),
|
|
||||||
)
|
|
||||||
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
|
||||||
assert.Contains(t, body, `<nav class="app-bar"`)
|
|
||||||
assert.Contains(
|
|
||||||
t, body, strconv.Itoa(status)+" "+http.StatusText(status),
|
|
||||||
)
|
|
||||||
assert.Contains(t, body, link)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorPage_DeletedWebhook(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "owner")
|
|
||||||
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
require.NoError(t, env.db.DB().Delete(wh).Error)
|
|
||||||
|
|
||||||
w := env.get("/source/"+wh.ID, cookies)
|
|
||||||
|
|
||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorPage_DeletedTarget(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "owner")
|
|
||||||
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
tgt := env.seedTarget(t, wh.ID)
|
|
||||||
require.NoError(t, env.db.DB().Delete(tgt).Error)
|
|
||||||
|
|
||||||
w := env.get(
|
|
||||||
"/source/"+wh.ID+"/targets/"+tgt.ID+"/edit", cookies,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorPage_UnknownPath(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "owner")
|
|
||||||
|
|
||||||
assertErrorPage(
|
|
||||||
t, env.get("/no-such-page", nil),
|
|
||||||
http.StatusNotFound, backToSignIn,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Outside every route group there is no form token, so the
|
|
||||||
// page leaves out the logout form rather than offer one that
|
|
||||||
// would be refused.
|
|
||||||
w := env.get("/no-such-page", cookies)
|
|
||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
|
||||||
assert.NotContains(t, w.Body.String(), `action="/pages/logout"`)
|
|
||||||
|
|
||||||
// Inside a route group the page has a token, and logout works.
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
w = env.get("/source/"+wh.ID+"/no-such-page", cookies)
|
|
||||||
assertErrorPage(t, w, http.StatusNotFound, backToWebhooks)
|
|
||||||
assert.Contains(t, w.Body.String(), `action="/pages/logout"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorPage_BadCSRFToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("username", "someone")
|
|
||||||
form.Set("password", "irrelevant")
|
|
||||||
form.Set("csrf_token", "not-a-token")
|
|
||||||
|
|
||||||
assertErrorPage(
|
|
||||||
t, env.post("/pages/login", form, nil),
|
|
||||||
http.StatusForbidden, backToSignIn,
|
|
||||||
)
|
|
||||||
|
|
||||||
userID, _ := env.seedUser(t, "owner", "somepassword")
|
|
||||||
cookies := env.authCookies(t, userID, "owner")
|
|
||||||
wh := env.seedWebhook(t, userID)
|
|
||||||
|
|
||||||
edit := url.Values{}
|
|
||||||
edit.Set("name", "renamed")
|
|
||||||
|
|
||||||
assertErrorPage(
|
|
||||||
t, env.post("/source/"+wh.ID+"/edit", edit, cookies),
|
|
||||||
http.StatusForbidden, backToWebhooks,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestErrorPage_PanicOnAdminPage sends a panicking handler in an
|
|
||||||
// admin page route group through the real router, with error
|
|
||||||
// tracking on: the client gets the 500 error page, and the tracker
|
|
||||||
// still gets the panic, once. The same panic outside the admin page
|
|
||||||
// route groups keeps the plain 500.
|
|
||||||
func TestErrorPage_PanicOnAdminPage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
transport := &captureTransport{}
|
|
||||||
|
|
||||||
opts := server.SentryClientOptionsForTest(
|
|
||||||
"https://public@sentry.invalid/1", "webhooker-test",
|
|
||||||
)
|
|
||||||
opts.Transport = transport
|
|
||||||
|
|
||||||
client, err := sentry.NewClient(opts)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
serve := func(router http.Handler, path string) *httptest.ResponseRecorder {
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
sentry.SetHubOnContext(
|
|
||||||
context.Background(),
|
|
||||||
sentry.NewHub(client, sentry.NewScope()),
|
|
||||||
),
|
|
||||||
http.MethodGet, path, nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
w := serve(
|
|
||||||
server.NewRouterWithPageProbeForTest(
|
|
||||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
|
||||||
true, panicProbeHandler,
|
|
||||||
),
|
|
||||||
server.PageProbePattern,
|
|
||||||
)
|
|
||||||
assertErrorPage(t, w, http.StatusInternalServerError, backToSignIn)
|
|
||||||
|
|
||||||
w = serve(
|
|
||||||
server.NewRouterWithProbeForTest(
|
|
||||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
|
||||||
true, panicProbeHandler,
|
|
||||||
),
|
|
||||||
server.ProbePattern,
|
|
||||||
)
|
|
||||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
|
||||||
assert.Equal(t, "Internal Server Error\n", w.Body.String())
|
|
||||||
|
|
||||||
require.Len(t, transport.events, 2)
|
|
||||||
|
|
||||||
for _, event := range transport.events {
|
|
||||||
assert.Contains(t, marshalEvent(t, event), panicProbeMarker)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestErrorPage_ReceiverStaysPlain pins that the error page is for
|
|
||||||
// the web UI only: a sender posting to an entrypoint that does not
|
|
||||||
// exist still gets the plain-text answer.
|
|
||||||
func TestErrorPage_ReceiverStaysPlain(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// newTestEnv leaves the receiver rate limit at zero, which
|
|
||||||
// refuses every request before it reaches the receiver.
|
|
||||||
env := newTestEnvWithConfig(t, &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
Environment: config.EnvironmentDev,
|
|
||||||
ReceiverRateLimit: 10,
|
|
||||||
})
|
|
||||||
|
|
||||||
w := env.post("/webhook/no-such-entrypoint", url.Values{}, nil)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
||||||
assert.Equal(t, "404 page not found\n", w.Body.String())
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
"sneak.berlin/go/webhooker/internal/middleware"
|
||||||
@@ -102,39 +101,3 @@ func NewRouterWithProbeForTest(
|
|||||||
|
|
||||||
return s.router
|
return s.router
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageProbePattern is where NewRouterWithPageProbeForTest serves its
|
|
||||||
// probe: inside the /pages route group, the admin page group a
|
|
||||||
// request reaches without signing in.
|
|
||||||
const PageProbePattern = "/pages/probe"
|
|
||||||
|
|
||||||
// NewRouterWithPageProbeForTest is NewRouterWithProbeForTest with the
|
|
||||||
// probe added to the /pages route group once SetupRoutes has built
|
|
||||||
// it, so the probe runs behind that group's own middleware exactly as
|
|
||||||
// the group's real routes do.
|
|
||||||
func NewRouterWithPageProbeForTest(
|
|
||||||
log *slog.Logger,
|
|
||||||
cfg *config.Config,
|
|
||||||
mw *middleware.Middleware,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
sentryEnabled bool,
|
|
||||||
probe http.HandlerFunc,
|
|
||||||
) http.Handler {
|
|
||||||
s := &Server{
|
|
||||||
log: log,
|
|
||||||
mw: mw,
|
|
||||||
h: h,
|
|
||||||
params: ServerParams{Config: cfg},
|
|
||||||
}
|
|
||||||
s.sentryEnabled.Store(sentryEnabled)
|
|
||||||
s.SetupRoutes()
|
|
||||||
|
|
||||||
for _, route := range s.router.Routes() {
|
|
||||||
pages, ok := route.SubRoutes.(chi.Router)
|
|
||||||
if ok && route.Pattern == "/pages/*" {
|
|
||||||
pages.Get("/probe", probe)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.router
|
|
||||||
}
|
|
||||||
|
|||||||
+13
-44
@@ -15,10 +15,9 @@ import (
|
|||||||
// bytes) for form POST endpoints. 1 MB is generous for any form
|
// bytes) for form POST endpoints. 1 MB is generous for any form
|
||||||
// submission while preventing abuse from oversized payloads.
|
// submission while preventing abuse from oversized payloads.
|
||||||
//
|
//
|
||||||
// The four admin page route groups below (/pages, /user/{username},
|
// Every route group below installs MaxBodySize(maxFormBodySize) as
|
||||||
// /sources and /source/{sourceID}) install MaxBodySize(maxFormBodySize)
|
// its FIRST middleware, ahead of both CSRF and RequireAuth. Both
|
||||||
// right after their recoverer and error reporting, ahead of both CSRF
|
// orderings are deliberate.
|
||||||
// and RequireAuth. Both orderings are deliberate.
|
|
||||||
//
|
//
|
||||||
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
||||||
// be installed before anything reads the body, or the parse runs
|
// be installed before anything reads the body, or the parse runs
|
||||||
@@ -47,14 +46,6 @@ const requestTimeout = 60 * time.Second
|
|||||||
// server's router.
|
// server's router.
|
||||||
func (s *Server) SetupRoutes() {
|
func (s *Server) SetupRoutes() {
|
||||||
s.router = chi.NewRouter()
|
s.router = chi.NewRouter()
|
||||||
|
|
||||||
// An unknown path gets the error page. Registered before the
|
|
||||||
// global middleware, because chi wraps a not-found handler in the
|
|
||||||
// middleware already on its router, which would then run twice.
|
|
||||||
// The route groups below wrap it in their own middleware the same
|
|
||||||
// way; running theirs twice is harmless.
|
|
||||||
s.router.NotFound(s.h.HandleErrorPage(http.StatusNotFound))
|
|
||||||
|
|
||||||
s.setupGlobalMiddleware()
|
s.setupGlobalMiddleware()
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
}
|
}
|
||||||
@@ -78,33 +69,23 @@ func (s *Server) setupGlobalMiddleware() {
|
|||||||
// Panic recovery, deliberately here rather than first. It has to
|
// Panic recovery, deliberately here rather than first. It has to
|
||||||
// run inside every middleware that observes the response, so the
|
// run inside every middleware that observes the response, so the
|
||||||
// 500 it writes is the status the access log records and the
|
// 500 it writes is the status the access log records and the
|
||||||
// metrics count, and outside the sentryhttp handler, whose
|
// metrics count, and outside the sentryhttp handler below, whose
|
||||||
// Repanic option needs something further out to catch what it
|
// Repanic option needs something further out to catch what it
|
||||||
// re-raises. chi's own middleware.Recoverer held the first slot
|
// re-raises. chi's own middleware.Recoverer held the first slot
|
||||||
// until it was measured: on a current Go release it crashes
|
// until it was measured: on a current Go release it crashes
|
||||||
// inside its stack pretty-printer instead of recovering, so the
|
// inside its stack pretty-printer instead of recovering, so the
|
||||||
// connection dropped and the original panic was never reported.
|
// connection dropped and the original panic was never reported.
|
||||||
// See https://git.eeqj.de/sneak/webhooker/issues/187.
|
// See https://git.eeqj.de/sneak/webhooker/issues/187.
|
||||||
s.recoverPanics(s.router, nil)
|
s.router.Use(s.mw.Recoverer())
|
||||||
}
|
|
||||||
|
|
||||||
// recoverPanics installs on r the recoverer, answering a panic with
|
|
||||||
// page (a plain 500 when page is nil), and inside it the Sentry error
|
|
||||||
// reporting (if SENTRY_DSN is set). Repanic is true so panics still
|
|
||||||
// bubble up to the recoverer.
|
|
||||||
//
|
|
||||||
// Each admin page route group installs its own, with the error page,
|
|
||||||
// as its first middleware. A panic there is logged, reported and
|
|
||||||
// answered inside the group and never reaches the global recoverer,
|
|
||||||
// which keeps the plain 500 for every other route.
|
|
||||||
func (s *Server) recoverPanics(r chi.Router, page http.Handler) {
|
|
||||||
r.Use(s.mw.Recoverer(page))
|
|
||||||
|
|
||||||
|
// Sentry error reporting (if SENTRY_DSN is set). Repanic is
|
||||||
|
// true so panics still bubble up to the Recoverer middleware
|
||||||
|
// registered immediately above.
|
||||||
if s.sentryEnabled.Load() {
|
if s.sentryEnabled.Load() {
|
||||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||||
Repanic: true,
|
Repanic: true,
|
||||||
})
|
})
|
||||||
r.Use(sentryHandler.Handle)
|
s.router.Use(sentryHandler.Handle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,13 +147,10 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupPageRoutes() {
|
func (s *Server) setupPageRoutes() {
|
||||||
s.router.Route("/pages", func(r chi.Router) {
|
s.router.Route("/pages", func(r chi.Router) {
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
|
|
||||||
// The login POST carries no pre-emptive rate limiter. Behind
|
// The login POST carries no pre-emptive rate limiter. Behind
|
||||||
@@ -191,13 +169,10 @@ func (s *Server) setupPageRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupUserRoutes() {
|
func (s *Server) setupUserRoutes() {
|
||||||
s.router.Route("/user/{username}", func(r chi.Router) {
|
s.router.Route("/user/{username}", func(r chi.Router) {
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleProfile())
|
r.Get("/", s.h.HandleProfile())
|
||||||
@@ -209,13 +184,10 @@ func (s *Server) setupUserRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupSourceRoutes() {
|
func (s *Server) setupSourceRoutes() {
|
||||||
s.router.Route("/sources", func(r chi.Router) {
|
s.router.Route("/sources", func(r chi.Router) {
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleSourceList())
|
r.Get("/", s.h.HandleSourceList())
|
||||||
@@ -224,13 +196,10 @@ func (s *Server) setupSourceRoutes() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
||||||
s.recoverPanics(
|
|
||||||
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
|
||||||
)
|
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
// see maxFormBodySize for why, and for what it costs.
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
r.Get("/", s.h.HandleSourceDetail())
|
r.Get("/", s.h.HandleSourceDetail())
|
||||||
|
|||||||
+6
-1
@@ -22,6 +22,11 @@
|
|||||||
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
|
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
|
||||||
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
||||||
# 67s, that is the datum to revisit the org figure with.
|
# 67s, that is the datum to revisit the org figure with.
|
||||||
|
#
|
||||||
|
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
|
||||||
|
# binaries build or run at once, each with at most eight parallel tests. Under
|
||||||
|
# -race every test binary and every link costs a few hundred MB, so the
|
||||||
|
# defaults (one per core) add up to several GB on a many-core host.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -29,7 +34,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
"$ROOT/script/assets"
|
"$ROOT/script/assets"
|
||||||
go test -v -race -timeout 90s ./...
|
go test -v -race -p 4 -parallel 8 -timeout 90s ./...
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{{template "base" .}}
|
|
||||||
|
|
||||||
{{define "title"}}{{.StatusText}} - Webhooker{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<div class="max-w-4xl mx-auto px-6 py-12">
|
|
||||||
<h1 class="text-2xl font-medium text-gray-900 mb-4">{{.Status}} {{.StatusText}}</h1>
|
|
||||||
<p class="text-gray-600 mb-6">{{.Message}}</p>
|
|
||||||
{{if .User}}
|
|
||||||
<a href="/sources" class="btn-secondary">Back to webhooks</a>
|
|
||||||
{{else}}
|
|
||||||
<a href="/pages/login" class="btn-primary">Sign in</a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -24,14 +24,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{.User.Username}}
|
{{.User.Username}}
|
||||||
</a>
|
</a>
|
||||||
{{/* An error page can be served before a form token is issued,
|
|
||||||
and a logout without one is refused. */}}
|
|
||||||
{{if .CSRFToken}}
|
|
||||||
<form method="POST" action="/pages/logout" class="inline">
|
<form method="POST" action="/pages/logout" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-text">Logout</button>
|
<button type="submit" class="btn-text">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<a href="/pages/login" class="btn-primary">Login</a>
|
<a href="/pages/login" class="btn-primary">Login</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -44,12 +40,10 @@
|
|||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
|
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||||
{{if .CSRFToken}}
|
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<a href="/pages/login" class="btn-primary w-full">Login</a>
|
<a href="/pages/login" class="btn-primary w-full">Login</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{template "webhook_stats" .}}
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Entrypoints -->
|
<!-- Entrypoints -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
{{define "webhook_stats"}}
|
||||||
|
<!-- Statistics pane at the top of the webhook page. -->
|
||||||
|
<div class="card mb-6">
|
||||||
|
<div class="p-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Statistics</h2>
|
||||||
|
</div>
|
||||||
|
{{with .Stats}}
|
||||||
|
<div class="p-4 flex flex-wrap gap-6 text-sm border-b border-gray-200">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Entrypoints</span>
|
||||||
|
<span class="font-medium text-gray-900">{{.Entrypoints}}</span>
|
||||||
|
<span class="text-gray-500">({{.ActiveEntrypoints}} active)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Targets</span>
|
||||||
|
<span class="font-medium text-gray-900">{{.Targets}}</span>
|
||||||
|
<span class="text-gray-500">({{.ActiveTargets}} active)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Deliveries in progress</span>
|
||||||
|
<span class="font-medium text-gray-900">{{.InProgress}}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Last event</span>
|
||||||
|
<span class="font-medium text-gray-900">{{with .LastEventAt}}{{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Retention</span>
|
||||||
|
<span class="font-medium text-gray-900">{{$.Webhook.RetentionLabel}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 grid grid-cols-1 lg:grid-cols-2 gap-6 text-sm">
|
||||||
|
<div>
|
||||||
|
<table class="w-full text-center text-gray-900">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
||||||
|
<th></th>
|
||||||
|
<th class="py-2 font-medium">Lifetime</th>
|
||||||
|
<th class="py-2 font-medium">Within retention</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-left text-gray-600">Events</td>
|
||||||
|
<td class="py-2">{{.Lifetime.Events}}</td>
|
||||||
|
<td class="py-2">{{.WithinRetention.Events}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-left text-gray-600">Deliveries</td>
|
||||||
|
<td class="py-2">{{.Lifetime.Deliveries}}</td>
|
||||||
|
<td class="py-2">{{.WithinRetention.Deliveries}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-left text-gray-600">Failures</td>
|
||||||
|
<td class="py-2">{{.Lifetime.Failures}}</td>
|
||||||
|
<td class="py-2">{{.WithinRetention.Failures}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<table class="w-full text-center text-gray-900">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
||||||
|
<th></th>
|
||||||
|
<th class="py-2 font-medium">Last 10 minutes</th>
|
||||||
|
<th class="py-2 font-medium">Last 24 hours</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-left text-gray-600">Events</td>
|
||||||
|
<td class="py-2">{{.Last10Minutes.Events}}</td>
|
||||||
|
<td class="py-2">{{.Last24Hours.Events}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-left text-gray-600">Failures</td>
|
||||||
|
<td class="py-2">{{.Last10Minutes.Failed}}</td>
|
||||||
|
<td class="py-2">{{.Last24Hours.Failed}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 text-left text-gray-600">Failure percentage</td>
|
||||||
|
<td class="py-2">{{.Last10Minutes.FailurePercent}}</td>
|
||||||
|
<td class="py-2">{{.Last24Hours.FailurePercent}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">Failure percentage is the failed deliveries out of all deliveries that finished in the window. Deliveries still pending or retrying are not counted.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="p-4 text-sm text-gray-500">The statistics could not be read.</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user