Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
840c795c66 |
@@ -1065,7 +1065,7 @@ unconditionally against whatever files it finds:
|
||||
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
||||
`Entrypoint`, `Target`
|
||||
- each event database when it is lazily opened — `Event`, `Delivery`,
|
||||
`DeliveryResult`
|
||||
`DeliveryResult`, `EventTotals`, `TargetTotals`
|
||||
- each archive database on every open and reopen
|
||||
|
||||
There is no schema version table, no migration ledger, and no down
|
||||
@@ -1381,7 +1381,7 @@ The codebase uses consistent naming throughout (rename completed in
|
||||
|
||||
### Data Model
|
||||
|
||||
webhooker's data model has nine entities organized into two tiers: the
|
||||
webhooker's data model has eleven entities organized into two tiers: the
|
||||
**application tier** (user and webhook configuration) and the **event
|
||||
tier** (event ingestion, delivery, and logging).
|
||||
|
||||
@@ -1410,6 +1410,13 @@ tier** (event ingestion, delivery, and logging).
|
||||
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
||||
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
||||
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────┐ (one row: running counts of events) │
|
||||
│ │ EventTotals │ │
|
||||
│ └──────────────┘ │
|
||||
│ ┌──────────────┐ (one row per target: running counts │
|
||||
│ │ TargetTotals │ of its deliveries) │
|
||||
│ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -1662,6 +1669,7 @@ status across potentially multiple attempts.
|
||||
| `event_id` | UUID | Foreign key → Event |
|
||||
| `target_id`| UUID | Foreign key → Target |
|
||||
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
||||
| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) |
|
||||
|
||||
**Relations:** Belongs to Event. Belongs to Target. Has many
|
||||
DeliveryResults.
|
||||
@@ -1729,33 +1737,65 @@ retries) is individually logged for full observability.
|
||||
|
||||
**Relations:** Belongs to Delivery.
|
||||
|
||||
#### EventTotals and TargetTotals
|
||||
|
||||
Running counts in each event database, read by the statistics pane at the
|
||||
top of the webhook page. `EventTotals` is one row:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | ------- | ----------- |
|
||||
| `events` | integer | Events ever stored, resubmitted copies included |
|
||||
| `events_removed` | integer | Events retention has deleted |
|
||||
|
||||
`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-10-minutes and
|
||||
last-24-hours figures are counted from the `events` and `deliveries`
|
||||
indexes over just that window, the deliveries in one query grouped by
|
||||
target. Its failure percentage for a window is the deliveries that became
|
||||
`failed` in it out of all that became `delivered` or `failed` in it, and
|
||||
a dash when none did.
|
||||
|
||||
#### Event-tier indexes
|
||||
|
||||
These indexes on the per-webhook event databases are declared in the model
|
||||
tags, so `AutoMigrate` creates them on a fresh and on an existing database:
|
||||
tags, so `AutoMigrate` creates them on a fresh database:
|
||||
|
||||
| Table | Columns | Serves |
|
||||
| ------------------ | --------------------------- | ------ |
|
||||
| `deliveries` | `status`, `deleted_at` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status |
|
||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which selects and deletes the deliveries of expired events |
|
||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count each target's deliveries by status and when they finished |
|
||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
||||
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
||||
| `events` | `deleted_at`, `created_at` | Retention, which selects expired events by age |
|
||||
| `events` | `created_at` | Retention's delete of the expired events themselves |
|
||||
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events and find the newest |
|
||||
| `events` | `created_at` | Retention, which selects expired events by age |
|
||||
|
||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention's
|
||||
deletes leave it out, but their lookups of expired rows keep it. SQLite keeps
|
||||
no statistics on these tables, and without them it rates the `deleted_at`
|
||||
index, which every live row matches, above an index on a column matched
|
||||
against several values or compared with `<`. So every index but the last also
|
||||
covers `deleted_at`. It comes second, so that retention's deletes can use the
|
||||
index without it, except in `events`, where `created_at` is compared with `<`
|
||||
and SQLite narrows by a `<` only on the last column it uses.
|
||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
||||
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
||||
rates the `deleted_at` index, which every live row matches, above an index on
|
||||
a column matched against several values or compared with `<`. So every index
|
||||
but the last also covers `deleted_at`. It comes second, so that retention can
|
||||
use the index without it, except in `events`, where `created_at` is compared
|
||||
with `<` and SQLite narrows by a `<` only on the last column it uses.
|
||||
|
||||
#### Common Fields
|
||||
|
||||
Every entity except `Setting` includes these fields from `BaseModel`.
|
||||
`Setting` is a bare key-value row with no `id`, no timestamps and no
|
||||
soft delete:
|
||||
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes
|
||||
these fields from `BaseModel`. `Setting` is a bare key-value row with no
|
||||
`id`, no timestamps and no soft delete, and the two totals tables hold
|
||||
only counts, keyed by a numeric `id` and by `target_id`:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------ | --------- | ----------- |
|
||||
@@ -1797,6 +1837,8 @@ encryption key is generated and stored, and an `admin` user is created.
|
||||
- **Events** — captured incoming webhook payloads
|
||||
- **Deliveries** — event-to-target pairings and their status
|
||||
- **DeliveryResults** — individual delivery attempt logs
|
||||
- **EventTotals** and **TargetTotals** — running counts of the above,
|
||||
the deliveries per target, kept through retention
|
||||
|
||||
Per-webhook databases are created automatically when a webhook is
|
||||
created (and lazily on first access for webhooks that predate this
|
||||
@@ -2692,16 +2734,12 @@ abuse limit later; they are tracked as future work.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | --------------- | ----------- |
|
||||
| `GET` | `/pages/login` | Login page (not rate limited). Its `next` parameter names the page to return to after login; anything but a path on this site is replaced with `/` |
|
||||
| `POST` | `/pages/login` | Login form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) |
|
||||
| `GET` | `/pages/login` | Login page (not rate limited) |
|
||||
| `POST` | `/pages/login` | Login form submission. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) |
|
||||
| `POST` | `/pages/logout` | Logout (destroys session) |
|
||||
|
||||
#### Authenticated Endpoints
|
||||
|
||||
A logged-out `GET` of any of these is redirected to `/pages/login` with
|
||||
its path and query as `next`, so logging in returns to the page that was
|
||||
asked for.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------ | ----------- |
|
||||
| `GET` | `/user/{username}` | User profile page |
|
||||
@@ -2781,6 +2819,7 @@ webhooker/
|
||||
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
||||
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
||||
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
||||
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB)
|
||||
│ │ ├── model_apikey.go # APIKey entity
|
||||
│ │ ├── password.go # Argon2id hashing and verification
|
||||
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
||||
|
||||
@@ -93,11 +93,11 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
||||
deliveries []database.Delivery
|
||||
results []database.DeliveryResult
|
||||
depths []struct{ Depth int }
|
||||
removed []database.TargetTotals
|
||||
)
|
||||
|
||||
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
||||
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
|
||||
byAge := "idx_events_deleted_at_created_at (deleted_at=? AND created_at<?)"
|
||||
|
||||
// The delivery engine: recovery and the retry sweep, the sweep for
|
||||
// stranded pending deliveries, and the queue depth count.
|
||||
@@ -123,25 +123,89 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
||||
Order("attempt_num ASC").Find(&results),
|
||||
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
|
||||
|
||||
// Retention's three deletes (reapExpired), whose subqueries are built
|
||||
// afresh for each statement as it builds them.
|
||||
expiredEventIDs := func() *gorm.DB {
|
||||
return dry.Model(&database.Event{}).Select("id").
|
||||
Where("created_at < ?", cutoff)
|
||||
}
|
||||
// Retention (reapExpired, deleteEvents): one batch of expired
|
||||
// events, then their attempts, deliveries and the events.
|
||||
var expired []string
|
||||
|
||||
assertPlanUses(t, db, dry.Unscoped().Model(&database.Event{}).
|
||||
Where("created_at < ?", cutoff).
|
||||
Limit(database.ExportReapBatchSize).Pluck("id", &expired),
|
||||
"idx_events_created_at (created_at<?)")
|
||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||
"delivery_id IN (?)", dry.Model(&database.Delivery{}).
|
||||
Select("id").Where("event_id IN (?)", expiredEventIDs()),
|
||||
"delivery_id IN (?)", dry.Unscoped().Model(&database.Delivery{}).
|
||||
Select("id").Where("event_id IN ?", ids),
|
||||
).Delete(&database.DeliveryResult{}),
|
||||
"idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
|
||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||
"event_id IN (?)", expiredEventIDs(),
|
||||
).Delete(&database.Delivery{}),
|
||||
"idx_deliveries_event_id (event_id=?)", byAge)
|
||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||
"created_at < ?", cutoff,
|
||||
).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
|
||||
"idx_delivery_results_delivery_id (delivery_id=?)",
|
||||
"idx_deliveries_event_id (event_id=?)")
|
||||
assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}).
|
||||
Select("target_id, count(*) AS deliveries_removed, "+
|
||||
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
|
||||
database.DeliveryStatusFailed).
|
||||
Where("event_id IN ?", ids).Group("target_id").Find(&removed),
|
||||
"idx_deliveries_event_id (event_id=?)")
|
||||
assertPlanUses(t, db, dry.Unscoped().Where("event_id IN ?", ids).
|
||||
Delete(&database.Delivery{}), "idx_deliveries_event_id (event_id=?)")
|
||||
assertPlanUses(t, db, dry.Unscoped().Where("id IN ?", ids).
|
||||
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
|
||||
}
|
||||
|
||||
// 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, events received since a time, and the
|
||||
// newest event, which must come straight off an index rather than from
|
||||
// sorting every event.
|
||||
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
|
||||
newest []time.Time
|
||||
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>?)")
|
||||
|
||||
newestEvent := dry.Model(&database.Event{}).
|
||||
Order("created_at DESC").Limit(1).Pluck("created_at", &newest)
|
||||
assertPlanUses(t, db, newestEvent,
|
||||
"idx_events_deleted_at_created_at (deleted_at=?)")
|
||||
assert.NotContains(t, queryPlan(t, db, newestEvent), "TEMP B-TREE")
|
||||
}
|
||||
|
||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||
@@ -152,6 +216,18 @@ func assertPlanUses(
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
plan := queryPlan(t, db, built)
|
||||
|
||||
for _, index := range indexes {
|
||||
assert.Contains(t, plan, index, built.Statement.SQL.String())
|
||||
}
|
||||
}
|
||||
|
||||
// queryPlan returns SQLite's plan for a statement GORM built in a dry
|
||||
// run, run with the same SQL and arguments GORM would send.
|
||||
func queryPlan(t *testing.T, db, built *gorm.DB) string {
|
||||
t.Helper()
|
||||
|
||||
var plan []struct{ Detail string }
|
||||
|
||||
require.NoError(t, db.Raw(
|
||||
@@ -159,8 +235,5 @@ func assertPlanUses(
|
||||
built.Statement.Vars...,
|
||||
).Scan(&plan).Error)
|
||||
|
||||
for _, index := range indexes {
|
||||
assert.Contains(t, fmt.Sprint(plan), index,
|
||||
built.Statement.SQL.String())
|
||||
}
|
||||
return fmt.Sprint(plan)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ func NewTestRetentionReaper(
|
||||
}
|
||||
}
|
||||
|
||||
// ExportReapBatchSize exposes how many expired events one retention
|
||||
// transaction deletes.
|
||||
const ExportReapBatchSize = reapBatchSize
|
||||
|
||||
// ExportSweep runs a single retention sweep synchronously for tests.
|
||||
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
|
||||
r.sweep(ctx)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package database
|
||||
|
||||
import "gorm.io/gorm"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DeliveryStatus represents the status of a delivery
|
||||
type DeliveryStatus string
|
||||
@@ -37,7 +41,7 @@ type Delivery struct {
|
||||
BaseModel
|
||||
|
||||
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
|
||||
TargetID string `gorm:"type:uuid;not null" json:"targetId"`
|
||||
TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"`
|
||||
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
|
||||
|
||||
// DeletedAt repeats the BaseModel field only to be the second column
|
||||
@@ -45,6 +49,13 @@ type Delivery struct {
|
||||
// gives.
|
||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
|
||||
|
||||
// FinishedAt is when the delivery became delivered or failed, and
|
||||
// nil while it is pending or retrying. It and then TargetID end the
|
||||
// status index, so the webhook page counts each target's deliveries
|
||||
// that finished in a recent window by reading just that window from
|
||||
// the index.
|
||||
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
||||
|
||||
// Relations
|
||||
Event Event `json:"event,omitzero"`
|
||||
Target Target `json:"target,omitzero"`
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"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, and how many of them retention has deleted.
|
||||
type EventTotals struct {
|
||||
ID int64 `gorm:"primaryKey"`
|
||||
|
||||
Events int64 `gorm:"not null"`
|
||||
EventsRemoved int64 `gorm:"not null"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 + ?`,
|
||||
add.Events, add.EventsRemoved,
|
||||
).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding to event totals: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddTargetTotals adds each count in add to the totals of the target
|
||||
// add.TargetID names, creating its row the first time. Call it on the
|
||||
// transaction that writes or deletes the deliveries it counts.
|
||||
func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
|
||||
err := tx.Exec(
|
||||
`INSERT INTO target_totals (target_id, deliveries, delivered,
|
||||
failed, deliveries_removed, failed_removed)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (target_id) DO UPDATE SET
|
||||
deliveries = deliveries + excluded.deliveries,
|
||||
delivered = delivered + excluded.delivered,
|
||||
failed = failed + excluded.failed,
|
||||
deliveries_removed =
|
||||
deliveries_removed + excluded.deliveries_removed,
|
||||
failed_removed = failed_removed + excluded.failed_removed`,
|
||||
add.TargetID, add.Deliveries, add.Delivered,
|
||||
add.Failed, add.DeliveriesRemoved, add.FailedRemoved,
|
||||
).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"adding to totals of target %s: %w", add.TargetID, err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -2,7 +2,8 @@ package database
|
||||
|
||||
// Migrate runs database migrations for the main application database.
|
||||
// Only configuration-tier models are stored in the main database.
|
||||
// Event-tier models (Event, Delivery, DeliveryResult) live in
|
||||
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
||||
// TargetTotals) live in
|
||||
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||
func (d *Database) Migrate() error {
|
||||
return d.db.AutoMigrate(
|
||||
|
||||
@@ -18,6 +18,13 @@ import (
|
||||
// computation.
|
||||
const hoursPerDay = 24
|
||||
|
||||
// reapBatchSize is how many expired events one retention transaction
|
||||
// deletes. A transaction holds the event database's write lock, which
|
||||
// the receiver and the delivery workers wait for, so a large prune is
|
||||
// split into transactions each short enough to finish well inside the
|
||||
// busy timeout.
|
||||
const reapBatchSize = 1000
|
||||
|
||||
// RetentionReaperParams holds the fx dependencies for the
|
||||
// RetentionReaper.
|
||||
type RetentionReaperParams struct {
|
||||
@@ -265,57 +272,97 @@ func retentionCutoff(
|
||||
), true
|
||||
}
|
||||
|
||||
// reapExpired hard-deletes, in foreign-key-safe order, the delivery
|
||||
// results, deliveries, and events associated with events older than
|
||||
// cutoff. Deletes are unscoped so rows are physically removed rather
|
||||
// than soft-deleted, reclaiming disk. It returns the number of events
|
||||
// reapExpired hard-deletes the events older than cutoff, with their
|
||||
// deliveries and delivery results, reapBatchSize events per
|
||||
// transaction until none is left. It returns the number of events
|
||||
// deleted.
|
||||
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
||||
// Fresh subqueries are built per statement to avoid reusing a
|
||||
// mutated builder across executions.
|
||||
expiredEventIDs := func() *gorm.DB {
|
||||
return db.Model(&Event{}).
|
||||
Select("id").
|
||||
Where("created_at < ?", cutoff)
|
||||
}
|
||||
expiredDeliveryIDs := func() *gorm.DB {
|
||||
return db.Model(&Delivery{}).
|
||||
Select("id").
|
||||
Where("event_id IN (?)", expiredEventIDs())
|
||||
}
|
||||
var total int64
|
||||
|
||||
// 1. Delivery results whose delivery belongs to an expired event.
|
||||
res := db.Unscoped().
|
||||
Where("delivery_id IN (?)", expiredDeliveryIDs()).
|
||||
Delete(&DeliveryResult{})
|
||||
if res.Error != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"deleting expired delivery results: %w",
|
||||
res.Error,
|
||||
)
|
||||
}
|
||||
for {
|
||||
var eventIDs []string
|
||||
|
||||
// 2. Deliveries belonging to an expired event.
|
||||
del := db.Unscoped().
|
||||
Where("event_id IN (?)", expiredEventIDs()).
|
||||
Delete(&Delivery{})
|
||||
if del.Error != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"deleting expired deliveries: %w",
|
||||
del.Error,
|
||||
)
|
||||
}
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Unscoped().Model(&Event{}).
|
||||
Where("created_at < ?", cutoff).
|
||||
Limit(reapBatchSize).
|
||||
Pluck("id", &eventIDs).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("selecting expired events: %w", err)
|
||||
}
|
||||
|
||||
// 3. The expired events themselves.
|
||||
ev := db.Unscoped().
|
||||
Where("created_at < ?", cutoff).
|
||||
Delete(&Event{})
|
||||
if ev.Error != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"deleting expired events: %w",
|
||||
ev.Error,
|
||||
)
|
||||
}
|
||||
if len(eventIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,228 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
@@ -35,7 +35,8 @@ var errInvalidCachedDBType = errors.New(
|
||||
|
||||
// WebhookDBManager manages per-webhook SQLite database files
|
||||
// for event storage. Each webhook gets its own dedicated
|
||||
// database containing Events, Deliveries, and DeliveryResults.
|
||||
// database containing Events, Deliveries, DeliveryResults and the
|
||||
// running totals of them (EventTotals, TargetTotals).
|
||||
// Database connections are opened lazily and cached.
|
||||
type WebhookDBManager struct {
|
||||
dataDir string
|
||||
@@ -295,6 +296,7 @@ func (m *WebhookDBManager) openDB(
|
||||
// Run migrations for event-tier models only
|
||||
err = db.AutoMigrate(
|
||||
&Event{}, &Delivery{}, &DeliveryResult{},
|
||||
&EventTotals{}, &TargetTotals{},
|
||||
)
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
@@ -305,6 +307,18 @@ func (m *WebhookDBManager) openDB(
|
||||
)
|
||||
}
|
||||
|
||||
// A new database gets its row of event totals, all zero. Target
|
||||
// totals rows are created by the first delivery to each target.
|
||||
err = db.FirstOrCreate(&EventTotals{}).Error
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"creating event totals for webhook database %s: %w",
|
||||
webhookID, err,
|
||||
)
|
||||
}
|
||||
|
||||
m.log.Info(
|
||||
"opened per-webhook database",
|
||||
"webhook_id", webhookID,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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))
|
||||
}
|
||||
@@ -1554,8 +1554,9 @@ func (e *Engine) updateDeliveryStatus(
|
||||
targetType database.TargetType,
|
||||
status database.DeliveryStatus,
|
||||
) error {
|
||||
err := webhookDB.Model(d).
|
||||
Update("status", status).Error
|
||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||
return writeDeliveryStatus(tx, d, status)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"updating delivery %s to status %s: %w",
|
||||
@@ -1574,6 +1575,36 @@ func (e *Engine) updateDeliveryStatus(
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeDeliveryStatus writes a delivery's new status. A delivery that
|
||||
// becomes delivered or failed also gets the time it finished, and is
|
||||
// added to its target's delivered or failed total. It is counted only
|
||||
// if the row was still there to update: retention may have deleted it
|
||||
// while the engine was working on it.
|
||||
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).Updates(map[string]any{
|
||||
"status": status,
|
||||
"finished_at": time.Now(),
|
||||
})
|
||||
if res.Error != nil || res.RowsAffected == 0 {
|
||||
return res.Error
|
||||
}
|
||||
|
||||
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
||||
if status == database.DeliveryStatusFailed {
|
||||
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
||||
}
|
||||
|
||||
return database.AddTargetTotals(tx, add)
|
||||
}
|
||||
|
||||
// settleStatus moves a delivery to its outcome status and reports a
|
||||
// failed write through bookkeepingFailed, which leaves the row
|
||||
// recoverable. It exists so the target call sites read as one
|
||||
|
||||
@@ -57,7 +57,10 @@ func testWebhookDB(t *testing.T) *gorm.DB {
|
||||
&database.Event{},
|
||||
&database.Delivery{},
|
||||
&database.DeliveryResult{},
|
||||
&database.EventTotals{},
|
||||
&database.TargetTotals{},
|
||||
))
|
||||
require.NoError(t, db.Create(&database.EventTotals{}).Error)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -150,6 +150,16 @@ func (e *Engine) ExportDeliverSlack(
|
||||
)
|
||||
}
|
||||
|
||||
// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no
|
||||
// target type, so no metric moves.
|
||||
func (e *Engine) ExportUpdateDeliveryStatus(
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
status database.DeliveryStatus,
|
||||
) error {
|
||||
return e.updateDeliveryStatus(webhookDB, d, "", status)
|
||||
}
|
||||
|
||||
// ExportProcessNewTask exposes processNewTask.
|
||||
func (e *Engine) ExportProcessNewTask(
|
||||
ctx context.Context, task *Task,
|
||||
|
||||
@@ -2,62 +2,19 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/logfield"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
)
|
||||
|
||||
// maxNextBytes bounds the page to return to after login. The login
|
||||
// page writes it into its form, and every page is rendered into a
|
||||
// buffer first (see executeTemplate), so without a bound a request
|
||||
// would choose the size of that buffer.
|
||||
const maxNextBytes = 2048
|
||||
|
||||
// loginDestination returns where a successful login sends the
|
||||
// browser: next when it is a path on this site, otherwise "/", which
|
||||
// leads to the webhook list.
|
||||
//
|
||||
// A browser reads "//host" as another site, reads "\" as "/", and
|
||||
// drops tabs and newlines before reading at all. So the value must
|
||||
// start with exactly one "/" and hold no "\" or control character
|
||||
// anywhere: http.Redirect cleans "/a/../\host" down to "/\host". It
|
||||
// is checked after percent-decoding, so an encoded form of any of
|
||||
// these is refused too.
|
||||
func loginDestination(next string) string {
|
||||
if len(next) > maxNextBytes {
|
||||
return "/"
|
||||
}
|
||||
|
||||
decoded, err := url.PathUnescape(next)
|
||||
if err != nil ||
|
||||
!strings.HasPrefix(decoded, "/") ||
|
||||
strings.HasPrefix(decoded, "//") ||
|
||||
strings.Contains(decoded, `\`) ||
|
||||
strings.ContainsFunc(decoded, unicode.IsControl) {
|
||||
return "/"
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
// HandleLoginPage returns a handler for the login page (GET)
|
||||
func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
next := loginDestination(
|
||||
r.URL.Query().Get(middleware.NextParam),
|
||||
)
|
||||
|
||||
// Check if already logged in
|
||||
sess, err := h.session.Get(r)
|
||||
if err == nil && h.session.IsAuthenticated(sess) {
|
||||
http.Redirect( //nolint:gosec // checked by loginDestination
|
||||
w, r, next, http.StatusSeeOther,
|
||||
)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -65,7 +22,6 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
||||
// Render login page
|
||||
data := map[string]any{
|
||||
tmplKeyError: "",
|
||||
tmplKeyNext: next,
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "login.html", data)
|
||||
@@ -121,13 +77,8 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
||||
"user_id", user.ID,
|
||||
)
|
||||
|
||||
// The form value is the client's to set, so it is checked
|
||||
// again here rather than trusted from the rendered page.
|
||||
http.Redirect( //nolint:gosec // checked by loginDestination
|
||||
w, r,
|
||||
loginDestination(r.PostFormValue(middleware.NextParam)),
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
// Redirect to home page
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,9 +91,6 @@ func (h *Handlers) renderLoginError(
|
||||
) {
|
||||
data := map[string]any{
|
||||
tmplKeyError: msg,
|
||||
tmplKeyNext: loginDestination(
|
||||
r.PostFormValue(middleware.NextParam),
|
||||
),
|
||||
}
|
||||
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -454,151 +454,6 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// TestLogin_ReturnsOnlyToAPathOnThisSite is the security half of
|
||||
// https://git.eeqj.de/sneak/webhooker/issues/384: the page a login
|
||||
// returns to is client-chosen, so anything that is not a path on this
|
||||
// site, plain or percent-encoded, must land on "/", the webhook list.
|
||||
func TestLogin_ReturnsOnlyToAPathOnThisSite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
db *database.Database
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &db)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
seedOperator(t, db)
|
||||
|
||||
cases := []struct{ next, want string }{
|
||||
{"/source/abc/logs?page=2", "/source/abc/logs?page=2"},
|
||||
{"", "/"},
|
||||
{"https://evil.example/", "/"},
|
||||
{"https%3A%2F%2Fevil.example%2F", "/"},
|
||||
{"//evil.example/", "/"},
|
||||
{"%2F%2Fevil.example/", "/"},
|
||||
{"/%2Fevil.example/", "/"},
|
||||
{`/\evil.example/`, "/"},
|
||||
{"%2F%5Cevil.example/", "/"},
|
||||
{"/%5Cevil.example/", "/"},
|
||||
{`/a/../\evil.example/`, "/"},
|
||||
{"/\t/evil.example/", "/"},
|
||||
{"/%09/evil.example/", "/"},
|
||||
{"/" + strings.Repeat("a", 4096), "/"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
form := url.Values{}
|
||||
form.Set("username", operatorUser)
|
||||
form.Set("password", operatorPassword)
|
||||
form.Set("next", c.next)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"/pages/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
req.Header.Set(
|
||||
"Content-Type", "application/x-www-form-urlencoded",
|
||||
)
|
||||
req.RemoteAddr = sharedProxyPeer
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleLoginSubmit().ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next)
|
||||
assert.Equal(
|
||||
t, c.want, w.Header().Get("Location"), "next %q", c.next,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// loginPageGet renders the login page as a GET with the given next
|
||||
// value and cookies.
|
||||
func loginPageGet(
|
||||
h *handlers.Handlers, next string, cookies []*http.Cookie,
|
||||
) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet,
|
||||
"/pages/login?"+url.Values{"next": {next}}.Encode(), nil,
|
||||
)
|
||||
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.HandleLoginPage().ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// TestLoginPage_CarriesOnlyAPathOnThisSite covers the login page
|
||||
// itself: its form carries the requested page only when it is a path
|
||||
// on this site, and a browser already logged in goes straight there,
|
||||
// or to "/" when it is not.
|
||||
func TestLoginPage_CarriesOnlyAPathOnThisSite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
sess *session.Session
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &sess)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
assert.Contains(
|
||||
t, loginPageGet(h, "/source/abc", nil).Body.String(),
|
||||
`name="next" value="/source/abc"`,
|
||||
)
|
||||
assert.Contains(
|
||||
t, loginPageGet(h, "//evil.example/", nil).Body.String(),
|
||||
`name="next" value="/"`,
|
||||
)
|
||||
|
||||
cookies := authenticatedCookies(t, sess, "test-user-id", "testuser")
|
||||
|
||||
cases := []struct{ next, want string }{
|
||||
{"/source/abc", "/source/abc"},
|
||||
{"//evil.example/", "/"},
|
||||
{`/\evil.example/`, "/"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
w := loginPageGet(h, c.next, cookies)
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code, "next %q", c.next)
|
||||
assert.Equal(
|
||||
t, c.want, w.Header().Get("Location"), "next %q", c.next,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginPage_HasNoLinkToItself: the navigation bar on the login
|
||||
// page offers no link to the login page.
|
||||
func TestLoginPage_HasNoLinkToItself(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var h *handlers.Handlers
|
||||
|
||||
app := newTestApp(t, &h)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
w := loginPageGet(h, "", nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.NotContains(t, w.Body.String(), `href="/pages/login"`)
|
||||
}
|
||||
|
||||
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
|
||||
// database.MaxUsernameBytes still fits in the session cookie. Past
|
||||
// what the cookie can carry, a correct login answers 500.
|
||||
|
||||
@@ -299,8 +299,9 @@ func countInFlightDeliveries(
|
||||
return count, err
|
||||
}
|
||||
|
||||
// createReplayDelivery writes the new pending delivery row and returns
|
||||
// the task that carries it to the delivery engine.
|
||||
// createReplayDelivery writes the new pending delivery row, adds it to
|
||||
// its target's totals in the same transaction, and returns the task
|
||||
// that carries it to the delivery engine.
|
||||
//
|
||||
// The row is written with associations omitted, and neither Event nor
|
||||
// Target is populated on it: GORM's SaveBeforeAssociations would
|
||||
@@ -319,7 +320,16 @@ func createReplayDelivery(
|
||||
Status: database.DeliveryStatusPending,
|
||||
}
|
||||
|
||||
err := webhookDB.Omit(clause.Associations).Create(dlv).Error
|
||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Omit(clause.Associations).Create(dlv).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return database.AddTargetTotals(tx, database.TargetTotals{
|
||||
TargetID: dlv.TargetID, Deliveries: 1,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return delivery.Task{}, err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
@@ -69,6 +71,29 @@ func (s *Handlers) LoadEventLogViewsForTest(
|
||||
return views
|
||||
}
|
||||
|
||||
// WebhookStatsForTest returns the figures the statistics pane on a
|
||||
// webhook's page shows, from the webhook's entrypoints and targets
|
||||
// loaded as that page loads them.
|
||||
func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
|
||||
var entrypoints []database.Entrypoint
|
||||
|
||||
s.db.DB().Where("webhook_id = ?", webhookID).Find(&entrypoints)
|
||||
|
||||
var targets []database.Target
|
||||
|
||||
s.db.DB().Where("webhook_id = ?", webhookID).Find(&targets)
|
||||
|
||||
return s.loadWebhookStats(webhookID, entrypoints, targets)
|
||||
}
|
||||
|
||||
// FinishedByTargetForTest exposes finishedByTarget for use in the
|
||||
// handlers_test package.
|
||||
func FinishedByTargetForTest(
|
||||
webhookDB *gorm.DB, since time.Time,
|
||||
) ([]TargetFinished, error) {
|
||||
return finishedByTarget(webhookDB, since)
|
||||
}
|
||||
|
||||
// AddTemplateForTest registers a template under a page name so that
|
||||
// the handlers_test package can drive the render path with a
|
||||
// template of its own.
|
||||
|
||||
@@ -36,9 +36,6 @@ const (
|
||||
tmplKeyError = "Error"
|
||||
// tmplKeyWebhook is the template data key for a webhook.
|
||||
tmplKeyWebhook = "Webhook"
|
||||
// tmplKeyNext is the template data key for the page to return
|
||||
// to after login.
|
||||
tmplKeyNext = "Next"
|
||||
)
|
||||
|
||||
// errInvalidPassword is returned when a password does not match.
|
||||
@@ -94,18 +91,22 @@ type Handlers struct {
|
||||
|
||||
// parsePageTemplate parses a page-specific template set from the
|
||||
// embedded FS. Each page template is combined with the shared
|
||||
// base, htmlheader, and navbar templates. The page file must be
|
||||
// listed first so that its root action ({{template "base" .}})
|
||||
// becomes the template set's entry point.
|
||||
func parsePageTemplate(pageFile string) *template.Template {
|
||||
// base, htmlheader, and navbar templates, and with any further files
|
||||
// the page includes. The page file must be listed first so that its
|
||||
// root action ({{template "base" .}}) becomes the template set's entry
|
||||
// point.
|
||||
func parsePageTemplate(
|
||||
pageFile string, included ...string,
|
||||
) *template.Template {
|
||||
files := append([]string{
|
||||
pageFile,
|
||||
"base.html",
|
||||
"htmlheader.html",
|
||||
"navbar.html",
|
||||
}, included...)
|
||||
|
||||
return template.Must(
|
||||
template.ParseFS(
|
||||
templates.Templates,
|
||||
pageFile,
|
||||
"base.html",
|
||||
"htmlheader.html",
|
||||
"navbar.html",
|
||||
),
|
||||
template.ParseFS(templates.Templates, files...),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,7 +135,7 @@ func New(
|
||||
"profile.html": parsePageTemplate("profile.html"),
|
||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||
"source_detail.html": parsePageTemplate("source_detail.html"),
|
||||
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||
|
||||
@@ -160,10 +160,7 @@ func TestUserRoute_Unauthenticated_RedirectedByMiddleware(t *testing.T) {
|
||||
"handler must not be reached for unauthenticated request",
|
||||
)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, "/pages/login?next=%2Fuser%2Ftestuser",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// passwordChangeRequest builds a POST request to the password-change
|
||||
|
||||
@@ -450,6 +450,7 @@ func (h *Handlers) renderSourceDetail(
|
||||
"Targets": delivery.NewTargetViews(targets),
|
||||
"Events": events,
|
||||
"BaseURL": baseURL,
|
||||
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "source_detail.html", data)
|
||||
|
||||
@@ -252,11 +252,12 @@ func requestEventSource(
|
||||
}
|
||||
}
|
||||
|
||||
// createAndFanOut writes the event and one pending delivery per target
|
||||
// in a single transaction, then hands the tasks to the delivery
|
||||
// engine. It is the only path by which an event and its deliveries are
|
||||
// created, so a resubmitted event is retried, SSRF-guarded and
|
||||
// circuit-broken exactly as a received one is.
|
||||
// createAndFanOut writes the event and one pending delivery per target,
|
||||
// and adds them to the webhook's running totals, in a single
|
||||
// transaction, then hands the tasks to the delivery engine. It is the
|
||||
// only path by which an event and its deliveries are created, so a
|
||||
// resubmitted event is retried, SSRF-guarded and circuit-broken
|
||||
// exactly as a received one is.
|
||||
//
|
||||
// The tasks are returned as well as queued, so a caller can report how
|
||||
// many targets the event went to.
|
||||
@@ -296,6 +297,13 @@ func (h *Handlers) createAndFanOut(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = database.AddEventTotals(tx, database.EventTotals{Events: 1})
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf(
|
||||
@@ -354,8 +362,9 @@ func (h *Handlers) finishWebhookResponse(
|
||||
}
|
||||
|
||||
// buildDeliveryTasks creates one pending delivery per target in the
|
||||
// transaction and returns the tasks for the delivery engine. The
|
||||
// caller owns the transaction and rolls it back on error.
|
||||
// transaction, adds each to its target's totals, and returns the tasks
|
||||
// for the delivery engine. The caller owns the transaction and rolls
|
||||
// it back on error.
|
||||
func buildDeliveryTasks(
|
||||
tx *gorm.DB,
|
||||
event *database.Event,
|
||||
@@ -379,6 +388,13 @@ func buildDeliveryTasks(
|
||||
)
|
||||
}
|
||||
|
||||
err = database.AddTargetTotals(tx, database.TargetTotals{
|
||||
TargetID: targets[i].ID, Deliveries: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tasks = append(tasks, delivery.Task{
|
||||
DeliveryID: dlv.ID,
|
||||
EventID: event.ID,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
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 stored event arrived, or nil when
|
||||
// none is stored.
|
||||
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)
|
||||
}
|
||||
|
||||
var newest []time.Time
|
||||
|
||||
err = db.Model(&database.Event{}).
|
||||
Order("created_at DESC").
|
||||
Limit(1).
|
||||
Pluck("created_at", &newest).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading newest event time: %w", err)
|
||||
}
|
||||
|
||||
if len(newest) > 0 {
|
||||
stats.LastEventAt = &newest[0]
|
||||
}
|
||||
|
||||
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 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
|
||||
|
||||
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,442 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"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: one
|
||||
// day of retention, two entrypoints (one inactive) and three targets
|
||||
// (one inactive). Three events arrive through the receiver, and so
|
||||
// each has a delivery to the two active targets. The oldest event is
|
||||
// past retention, the middle one six hours old, the newest just in.
|
||||
// Their deliveries are settled as the delivery engine would, and a
|
||||
// replay adds a pending delivery to the oldest event.
|
||||
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: 1,
|
||||
}
|
||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||
|
||||
path := 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)
|
||||
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
||||
require.NoError(t, db.DB().Model(inactive).
|
||||
Update("active", false).Error)
|
||||
|
||||
router := receiverRouter(h)
|
||||
|
||||
for range 3 {
|
||||
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, 3)
|
||||
|
||||
oldest, middle, newest := events[0], events[1], events[2]
|
||||
now := time.Now()
|
||||
|
||||
statsAge(t, webhookDB, oldest.ID, now.Add(-50*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(-49*time.Hour))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, oldest.ID, second.ID),
|
||||
database.DeliveryStatusDelivered, now.Add(-49*time.Hour))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, middle.ID, first.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
|
||||
statsFinish(t, webhookDB,
|
||||
statsDelivery(t, webhookDB, middle.ID, second.ID),
|
||||
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
||||
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 statistics pane from a rendered webhook page:
|
||||
// everything from its heading to the next heading on the page.
|
||||
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")
|
||||
|
||||
return 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 three deliveries and the replay, the
|
||||
// second three; the inactive target has 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: 4, Delivered: 1, Failed: 2},
|
||||
second: {
|
||||
TargetID: second, Deliveries: 3, Delivered: 1, Failed: 1,
|
||||
},
|
||||
}, statsTargetTotals(t, hist.webhookDB))
|
||||
|
||||
lastDay, err := handlers.FinishedByTargetForTest(
|
||||
hist.webhookDB, time.Now().Add(-24*time.Hour),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []handlers.TargetFinished{
|
||||
{TargetID: first, Delivered: 1, Failed: 1},
|
||||
{TargetID: second, Failed: 1},
|
||||
}, lastDay)
|
||||
}
|
||||
|
||||
// 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, 2, stats.Entrypoints)
|
||||
assert.Equal(t, 1, stats.ActiveEntrypoints)
|
||||
assert.Equal(t, 3, stats.Targets)
|
||||
assert.Equal(t, 2, stats.ActiveTargets)
|
||||
assert.Equal(t, handlers.Counts{Events: 3, Deliveries: 7, Failures: 3},
|
||||
stats.Lifetime)
|
||||
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
|
||||
assert.Equal(t, int64(2), stats.InProgress)
|
||||
require.NotNil(t, stats.LastEventAt)
|
||||
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt))
|
||||
assert.Equal(t, handlers.RecentWindow{
|
||||
Events: 1, Delivered: 1, Failed: 1,
|
||||
}, stats.Last10Minutes)
|
||||
assert.Equal(t, handlers.RecentWindow{
|
||||
Events: 2, Delivered: 1, Failed: 2,
|
||||
}, stats.Last24Hours)
|
||||
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
|
||||
assert.Equal(t, "66.7%", 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 delivered 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: 2, Deliveries: 4, Failures: 2},
|
||||
after.WithinRetention)
|
||||
assert.Equal(t, int64(1), 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: 4, Delivered: 1, Failed: 2,
|
||||
DeliveriesRemoved: 2, FailedRemoved: 1,
|
||||
},
|
||||
second: {
|
||||
TargetID: second, Deliveries: 3, Delivered: 1, Failed: 1,
|
||||
DeliveriesRemoved: 1,
|
||||
},
|
||||
}, statsTargetTotals(t, hist.webhookDB))
|
||||
|
||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
||||
assert.Contains(t, pane, "Within retention")
|
||||
assert.Contains(t, pane, "50.0%")
|
||||
assert.Contains(t, pane, "66.7%")
|
||||
}
|
||||
|
||||
// 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)
|
||||
assert.Contains(t, pane, 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())
|
||||
|
||||
statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -367,24 +366,6 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// NextParam is the query parameter on the login redirect, and the
|
||||
// login form field, that holds the page to return to after login.
|
||||
const NextParam = "next"
|
||||
|
||||
// loginURL is the login page RequireAuth redirects to. A GET carries
|
||||
// its own path and query in NextParam so that logging in returns to
|
||||
// it; HandleLoginSubmit decides whether that value is safe to follow.
|
||||
// Other methods carry nothing, since a redirect cannot repeat them.
|
||||
func loginURL(r *http.Request) string {
|
||||
if r.Method != http.MethodGet {
|
||||
return "/pages/login"
|
||||
}
|
||||
|
||||
return "/pages/login?" + url.Values{
|
||||
NextParam: {r.URL.RequestURI()},
|
||||
}.Encode()
|
||||
}
|
||||
|
||||
// RequireAuth returns middleware that checks for a valid session.
|
||||
// Unauthenticated users are redirected to the login page.
|
||||
func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
||||
@@ -400,7 +381,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
||||
"error", err,
|
||||
)
|
||||
http.Redirect(
|
||||
w, r, loginURL(r), http.StatusSeeOther,
|
||||
w, r, "/pages/login", http.StatusSeeOther,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -428,7 +409,7 @@ func (s *Middleware) RequireAuth() func(http.Handler) http.Handler {
|
||||
),
|
||||
)
|
||||
http.Redirect(
|
||||
w, r, loginURL(r), http.StatusSeeOther,
|
||||
w, r, "/pages/login", http.StatusSeeOther,
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
@@ -338,42 +338,6 @@ func TestRequireAuth_NoSession_RedirectsToLogin(t *testing.T) {
|
||||
"unauthenticated request",
|
||||
)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
|
||||
// TestRequireAuth_LoginRedirectCarriesOnlyAGet pins what the login
|
||||
// redirect carries: a GET's path and query, so logging in can return
|
||||
// there, and nothing for a POST, which a redirect cannot repeat.
|
||||
func TestRequireAuth_LoginRedirectCarriesOnlyAGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
||||
|
||||
handler := m.RequireAuth()(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, _ *http.Request) {},
|
||||
))
|
||||
|
||||
get := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodGet, "/source/abc/logs?page=2", nil,
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, get)
|
||||
|
||||
assert.Equal(
|
||||
t, "/pages/login?next=%2Fsource%2Fabc%2Flogs%3Fpage%3D2",
|
||||
w.Header().Get("Location"),
|
||||
)
|
||||
|
||||
post := httptest.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost, "/source/abc/delete", nil,
|
||||
)
|
||||
w = httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, post)
|
||||
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
@@ -479,9 +443,7 @@ func TestRequireAuth_UnauthenticatedSession_RedirectsToLogin(
|
||||
"unauthenticated session",
|
||||
)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
|
||||
)
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// --- RequireAuth Session Expiry Tests ---
|
||||
@@ -579,9 +541,7 @@ func TestRequireAuth_IdleExpiredSession_RedirectsToLogin(
|
||||
"handler should not run for an idle-expired session",
|
||||
)
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(
|
||||
t, "/pages/login?next=%2Fdashboard", w.Header().Get("Location"),
|
||||
)
|
||||
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||
assert.Empty(
|
||||
t, sessionCookies(w),
|
||||
"an expired session must not be refreshed",
|
||||
|
||||
@@ -680,44 +680,6 @@ func TestPagesLogin_CookiesFromAnEarlierDatabase(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// TestPagesLogin_ReturnsToTheRequestedPage is
|
||||
// https://git.eeqj.de/sneak/webhooker/issues/384: a page opened while
|
||||
// logged out leads to the login page, and logging in from there lands
|
||||
// on that page, query included.
|
||||
func TestPagesLogin_ReturnsToTheRequestedPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
username = "operator"
|
||||
password = "correct-horse-battery-staple"
|
||||
)
|
||||
|
||||
env := newTestEnv(t)
|
||||
userID, _ := env.seedUser(t, username, password)
|
||||
asked := "/source/" + env.seedWebhook(t, userID).ID + "/logs?page=2"
|
||||
|
||||
bounced := env.get(asked, nil)
|
||||
require.Equal(t, http.StatusSeeOther, bounced.Code)
|
||||
|
||||
loginPage := bounced.Header().Get("Location")
|
||||
|
||||
match := regexp.MustCompile(`name="next" value="([^"]*)"`).
|
||||
FindStringSubmatch(env.get(loginPage, nil).Body.String())
|
||||
require.Len(t, match, 2, "the login form must carry the page")
|
||||
|
||||
token, cookies := env.csrfFrom(t, loginPage, nil)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", token)
|
||||
form.Set("username", username)
|
||||
form.Set("password", password)
|
||||
form.Set("next", html.UnescapeString(match[1]))
|
||||
|
||||
w := env.post("/pages/login", form, cookies)
|
||||
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||
assert.Equal(t, asked, w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// --- /user/{username} group ---
|
||||
|
||||
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
||||
@@ -868,10 +830,7 @@ func TestSourceLogsBody_OtherUser404s(t *testing.T) {
|
||||
|
||||
anon := env.get(path, nil)
|
||||
assert.Equal(t, http.StatusSeeOther, anon.Code)
|
||||
assert.Equal(
|
||||
t, "/pages/login?next="+url.QueryEscape(path),
|
||||
anon.Header().Get("Location"),
|
||||
)
|
||||
assert.Equal(t, "/pages/login", anon.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// TestDeliveryReplay_PostOnlyAndCSRFProtected walks the replay action
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
|
||||
<form method="POST" action="/pages/login" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="next" value="{{.Next}}">
|
||||
<div class="form-group">
|
||||
<label for="username" class="label">Username</label>
|
||||
<input
|
||||
|
||||
@@ -6,14 +6,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
{{if .User}}
|
||||
<button @click="open = !open" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{{end}}
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
@@ -30,6 +28,8 @@
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-text">Logout</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a href="/pages/login" class="btn-primary">Login</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,6 +44,8 @@
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-text w-full text-left">Logout</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a href="/pages/login" class="btn-primary w-full">Login</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "webhook_stats" .}}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Entrypoints -->
|
||||
<div class="card">
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
{{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}}{{.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">
|
||||
<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>
|
||||
<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