Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0b315c912 | ||
|
|
7402339afb | ||
|
|
ff24638ba4 | ||
|
|
da75950e91 |
@@ -1127,7 +1127,7 @@ unconditionally against whatever files it finds:
|
|||||||
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
||||||
`Entrypoint`, `Target`
|
`Entrypoint`, `Target`
|
||||||
- each event database when it is lazily opened — `Event`, `Delivery`,
|
- each event database when it is lazily opened — `Event`, `Delivery`,
|
||||||
`DeliveryResult`, `EventTotals`, `TargetTotals`
|
`DeliveryResult`, `EventTotals`, `TargetTotals`, `EntrypointTotals`
|
||||||
- each archive database on every open and reopen
|
- each archive database on every open and reopen
|
||||||
|
|
||||||
There is no schema version table, no migration ledger, and no down
|
There is no schema version table, no migration ledger, and no down
|
||||||
@@ -1344,10 +1344,13 @@ markup. The CSP build runs no expressions, so every Alpine directive in
|
|||||||
`x-data="{ open: false }"` or `@click="open = !open"`.
|
`x-data="{ open: false }"` or `@click="open = !open"`.
|
||||||
|
|
||||||
A browser test in `internal/server` loads the webhook page and the event log
|
A browser test in `internal/server` loads the webhook page and the event log
|
||||||
under the real policy and checks that: both add forms stay hidden until Add is
|
under the real policy and checks that: the add entrypoint form stays hidden
|
||||||
clicked; choosing Slack in the add target form leaves the HTTP fields out of
|
until Add is clicked; for every target type, the targets section's Add shows
|
||||||
what it submits, also after leaving the page and going back to it, when the
|
only a choice of type with Next and Cancel, Next shows only that type's fields
|
||||||
browser restores the choice; the Copy button beside an entrypoint URL reads
|
(no url field for `database` or `log`), Cancel at either step closes the form,
|
||||||
|
and saving adds the target; a refused target comes back with its form open, the
|
||||||
|
values entered and the reason, and after Cancel the next Add starts with an
|
||||||
|
empty form and no reason; the Copy button beside an entrypoint URL reads
|
||||||
"Copied" once clicked; an entrypoint's Edit button shows its edit form in place
|
"Copied" once clicked; an entrypoint's Edit button shows its edit form in place
|
||||||
of its description and hides until the form closes, Cancel hides the form and
|
of its description and hides until the form closes, Cancel hides the form and
|
||||||
drops what was typed, as does leaving the page and going back to it, and Save
|
drops what was typed, as does leaving the page and going back to it, and Save
|
||||||
@@ -1529,6 +1532,9 @@ tier** (event ingestion, delivery, and logging).
|
|||||||
│ ┌──────────────┐ (one row per target: running counts │
|
│ ┌──────────────┐ (one row per target: running counts │
|
||||||
│ │ TargetTotals │ of its deliveries) │
|
│ │ TargetTotals │ of its deliveries) │
|
||||||
│ └──────────────┘ │
|
│ └──────────────┘ │
|
||||||
|
│ ┌──────────────────┐ (one row per entrypoint: when the │
|
||||||
|
│ │ EntrypointTotals │ last event arrived on its URL) │
|
||||||
|
│ └──────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1642,6 +1648,11 @@ different event sources that all feed into the same processing pipeline
|
|||||||
(e.g., one entrypoint for GitHub, another for Stripe, both routing to
|
(e.g., one entrypoint for GitHub, another for Stripe, both routing to
|
||||||
the same targets).
|
the same targets).
|
||||||
|
|
||||||
|
The webhook page shows, for each entrypoint, when the last event arrived
|
||||||
|
on its URL, which retention leaves in place, or "never" if none ever has,
|
||||||
|
and how many events arrived on it within the webhook's retention period.
|
||||||
|
A resubmitted event did not arrive on the URL and counts in neither.
|
||||||
|
|
||||||
#### Target
|
#### Target
|
||||||
|
|
||||||
A delivery destination for events. Each target defines where and how
|
A delivery destination for events. Each target defines where and how
|
||||||
@@ -1851,10 +1862,11 @@ retries) is individually logged for full observability.
|
|||||||
|
|
||||||
**Relations:** Belongs to Delivery.
|
**Relations:** Belongs to Delivery.
|
||||||
|
|
||||||
#### EventTotals and TargetTotals
|
#### EventTotals, TargetTotals and EntrypointTotals
|
||||||
|
|
||||||
Running counts in each event database, read by the statistics pane at the
|
Running counts in each event database, read by the statistics pane at the
|
||||||
top of the webhook page and by the webhook list. `EventTotals` is one row:
|
top of the webhook page and by the webhook list, and each entrypoint's last
|
||||||
|
event, read by the webhook page's entrypoint list. `EventTotals` is one row:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------------- | --------- | ----------- |
|
| ---------------- | --------- | ----------- |
|
||||||
@@ -1873,18 +1885,26 @@ top of the webhook page and by the webhook list. `EventTotals` is one row:
|
|||||||
| `deliveries_removed` | integer | Its deliveries retention has deleted |
|
| `deliveries_removed` | integer | Its deliveries retention has deleted |
|
||||||
| `failed_removed` | integer | Its failed 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
|
`EntrypointTotals` is one row per entrypoint, created by the first event
|
||||||
counts. The pane's lifetime events are `events`, and its lifetime
|
that arrives on its URL:
|
||||||
deliveries and failures are `deliveries` and `failed` summed over the
|
|
||||||
targets; each figure within retention is the same less what retention
|
| Field | Type | Description |
|
||||||
removed, so neither needs the rows themselves. Its last event is
|
| --------------- | --------- | ----------- |
|
||||||
`last_event_at`, written in the transaction that stores the event, so it
|
| `entrypoint_id` | UUID | The entrypoint (primary key) |
|
||||||
still shows once retention has removed every event. Its last-10-minutes and
|
| `last_event_at` | timestamp | When the newest event arrived on its URL; a resubmitted event leaves it as it is, and so does retention |
|
||||||
last-24-hours figures are counted from the `events` and `deliveries`
|
|
||||||
indexes over just that window, the deliveries in one query grouped by
|
Each count changes in the transaction that writes or deletes the rows it counts,
|
||||||
target. Its failure percentage for a window is the deliveries that became
|
and each `last_event_at` in the transaction that stores the event. The pane's
|
||||||
`failed` in it out of all that became `delivered` or `failed` in it, and
|
lifetime events are `events`, and its lifetime deliveries and failures are
|
||||||
a dash when none did.
|
`deliveries` and `failed` summed over the targets; each figure within retention
|
||||||
|
is the same less what retention removed, so neither needs the rows themselves.
|
||||||
|
Its last event is `last_event_at` in `EventTotals`, so it still shows once
|
||||||
|
retention has removed every event; each entrypoint's last event, from
|
||||||
|
`EntrypointTotals`, does too. 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.
|
||||||
|
|
||||||
The webhook list at `/hooks` shows three of the pane's figures for each
|
The webhook list at `/hooks` shows three of the pane's figures for each
|
||||||
webhook: its events within retention and its last event, both from
|
webhook: its events within retention and its last event, both from
|
||||||
@@ -1906,26 +1926,31 @@ tags, so `AutoMigrate` creates them on a fresh database:
|
|||||||
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
||||||
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
||||||
| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page |
|
| `events` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page |
|
||||||
|
| `events` | `entrypoint_id`, `deleted_at`, `resubmitted_from_id`, `created_at` | The webhook page's entrypoint list, which counts the events that arrived on each entrypoint's URL within the retention period |
|
||||||
| `events` | `created_at` | Retention, which selects expired events by age |
|
| `events` | `created_at` | Retention, which selects expired events by age |
|
||||||
|
|
||||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention leaves
|
||||||
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
it out. SQLite keeps no statistics on these tables, and without them it rates
|
||||||
rates the `deleted_at` index, which every live row matches, above an index on
|
the `deleted_at` index, which every live row matches, above an index on a column
|
||||||
a column matched against several values or compared with a range. So every
|
matched against several values or compared with a range. So every index but the
|
||||||
index but the last also covers `deleted_at`. It comes second in the `event_id`
|
last also covers `deleted_at`. It comes second in the `event_id` and
|
||||||
and `delivery_id` indexes, so that retention can use them without it. The event
|
`delivery_id` indexes, so that retention can use them without it. The event
|
||||||
log's count, the one query on the `resubmitted_from_id` index, always carries
|
log's count, the one query on the `resubmitted_from_id` index, always carries
|
||||||
`deleted_at IS NULL` and uses both columns. In the statistics' `events` index
|
`deleted_at IS NULL` and uses both columns. The entrypoint list's count, the one
|
||||||
`deleted_at` comes first, because they compare `created_at` with a range (`>=`)
|
query on the `entrypoint_id` index, uses all four, `resubmitted_from_id IS NULL`
|
||||||
and SQLite narrows by a range only on the last column it uses.
|
leaving out resubmitted copies and `created_at` last because it compares it with
|
||||||
|
a range (`>=`). In the statistics' `events` index `deleted_at` comes first,
|
||||||
|
because they compare `created_at` with a range (`>=`) and SQLite narrows by a
|
||||||
|
range only on the last column it uses.
|
||||||
|
|
||||||
#### Common Fields
|
#### Common Fields
|
||||||
|
|
||||||
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes
|
Every entity except `Setting`, `EventTotals`, `TargetTotals` and
|
||||||
these fields from `BaseModel`. `Setting` is a bare key-value row with no
|
`EntrypointTotals` includes these fields from `BaseModel`. `Setting` is a bare
|
||||||
`id`, no timestamps and no soft delete, and the two totals tables hold
|
key-value row with no `id`, no timestamps and no soft delete. Of the three
|
||||||
counts, plus `last_event_at` in `event_totals`, keyed by a numeric `id`
|
totals tables, `event_totals` holds counts and `last_event_at`, keyed by a
|
||||||
and by `target_id`:
|
numeric `id`; `target_totals` holds counts, keyed by `target_id`; and
|
||||||
|
`entrypoint_totals` holds `last_event_at`, keyed by `entrypoint_id`:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------ | --------- | ----------- |
|
| ------------ | --------- | ----------- |
|
||||||
@@ -1967,8 +1992,9 @@ encryption key is generated and stored, and an `admin` user is created.
|
|||||||
- **Events** — captured incoming webhook payloads
|
- **Events** — captured incoming webhook payloads
|
||||||
- **Deliveries** — event-to-target pairings and their status
|
- **Deliveries** — event-to-target pairings and their status
|
||||||
- **DeliveryResults** — individual delivery attempt logs
|
- **DeliveryResults** — individual delivery attempt logs
|
||||||
- **EventTotals** and **TargetTotals** — running counts of the above,
|
- **EventTotals**, **TargetTotals** and **EntrypointTotals** — running
|
||||||
the deliveries per target, kept through retention
|
counts of the above, the deliveries per target, and each entrypoint's
|
||||||
|
last event, kept through retention
|
||||||
|
|
||||||
Per-webhook databases are created automatically when a webhook is
|
Per-webhook databases are created automatically when a webhook is
|
||||||
created. They are managed by the `WebhookDBManager` component, which
|
created. They are managed by the `WebhookDBManager` component, which
|
||||||
@@ -3033,7 +3059,7 @@ webhooker/
|
|||||||
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
||||||
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB)
|
│ │ ├── model_totals.go # EventTotals, TargetTotals and EntrypointTotals (per-webhook DB)
|
||||||
│ │ ├── model_apikey.go # APIKey entity
|
│ │ ├── model_apikey.go # APIKey entity
|
||||||
│ │ ├── password.go # Argon2id hashing and verification
|
│ │ ├── password.go # Argon2id hashing and verification
|
||||||
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
||||||
@@ -3309,9 +3335,9 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
`ENTRYPOINT` script, which sets the data directory's owner and mode
|
`ENTRYPOINT` script, which sets the data directory's owner and mode
|
||||||
before the app starts; the image's health check; and `docker exec`,
|
before the app starts; the image's health check; and `docker exec`,
|
||||||
unless given `--user`
|
unless given `--user`
|
||||||
- GORM soft deletes on every entity that carries `BaseModel`, which is
|
- GORM soft deletes on every entity that carries `BaseModel`, which is all of
|
||||||
all of them but `Setting`, `EventTotals` and `TargetTotals` (data
|
them but `Setting`, `EventTotals`, `TargetTotals` and `EntrypointTotals`
|
||||||
preserved for audit)
|
(data preserved for audit)
|
||||||
|
|
||||||
### Shutdown
|
### Shutdown
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,43 @@ func TestResubmitCountUsesItsIndex(t *testing.T) {
|
|||||||
"(resubmitted_from_id=? AND deleted_at=?)")
|
"(resubmitted_from_id=? AND deleted_at=?)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEntrypointEventsUseTheirIndex does the same for the webhook
|
||||||
|
// page's count, for each entrypoint, of the events that arrived on its
|
||||||
|
// URL since the retention cutoff (addEntrypointEvents in the
|
||||||
|
// handlers), which must come from the index alone. It passes 25
|
||||||
|
// entrypoints, as TestResubmitCountUsesItsIndex passes 25 events.
|
||||||
|
func TestEntrypointEventsUseTheirIndex(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})
|
||||||
|
|
||||||
|
entrypoints := make([]string, 25)
|
||||||
|
for i := range entrypoints {
|
||||||
|
entrypoints[i] = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []struct{ Events int }
|
||||||
|
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
||||||
|
Select("entrypoint_id, count(*) AS events").
|
||||||
|
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL",
|
||||||
|
entrypoints).
|
||||||
|
Where("created_at >= ?", time.Now()).
|
||||||
|
Group("entrypoint_id").Find(&rows),
|
||||||
|
"COVERING INDEX idx_events_entrypoint_id "+
|
||||||
|
"(entrypoint_id=? AND deleted_at=? AND "+
|
||||||
|
"resubmitted_from_id=? AND created_at>?)")
|
||||||
|
}
|
||||||
|
|
||||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||||
// in a dry run, run with the same SQL and arguments GORM would send,
|
// in a dry run, run with the same SQL and arguments GORM would send,
|
||||||
// names each of the given indexes.
|
// names each of the given indexes.
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ type Event struct {
|
|||||||
// has no deleted_at condition and uses the index on created_at
|
// has no deleted_at condition and uses the index on created_at
|
||||||
// alone. The other tables keep the unindexed BaseModel created_at.
|
// alone. The other tables keep the unindexed BaseModel created_at.
|
||||||
// DeletedAt is also the second column of the resubmitted_from_id
|
// DeletedAt is also the second column of the resubmitted_from_id
|
||||||
// index, for the reason DeliveryResult gives.
|
// index, for the reason DeliveryResult gives. The entrypoint_id
|
||||||
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
|
// index, for the webhook page's entrypoint list, has it second too,
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2" json:"deletedAt,omitzero"`
|
// resubmitted_from_id third, and created_at last, which the list
|
||||||
|
// compares with a range.
|
||||||
|
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2;index:idx_events_entrypoint_id,priority:4" json:"createdAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2;index:idx_events_entrypoint_id,priority:2" json:"deletedAt,omitzero"`
|
||||||
|
|
||||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||||
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
|
EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"`
|
||||||
|
|
||||||
// Request data
|
// Request data
|
||||||
Method string `gorm:"not null" json:"method"`
|
Method string `gorm:"not null" json:"method"`
|
||||||
@@ -44,7 +47,7 @@ type Event struct {
|
|||||||
// existed. It is not a foreign key: the source event can be
|
// existed. It is not a foreign key: the source event can be
|
||||||
// reaped by retention while its copies remain, and the id is
|
// reaped by retention while its copies remain, and the id is
|
||||||
// kept as the record of where the copy came from either way.
|
// kept as the record of where the copy came from either way.
|
||||||
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1" json:"resubmittedFromId,omitempty"`
|
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to, so
|
// Relations. No model marshals the record it belongs to, so
|
||||||
// Webhook and Entrypoint are left out of the JSON.
|
// Webhook and Entrypoint are left out of the JSON.
|
||||||
|
|||||||
@@ -52,6 +52,21 @@ func (TargetTotals) TableName() string {
|
|||||||
return "target_totals"
|
return "target_totals"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EntrypointTotals is one row per entrypoint, created by the first
|
||||||
|
// event that arrives on its URL: when the newest such event arrived,
|
||||||
|
// which retention leaves as it is. A resubmitted copy did not arrive
|
||||||
|
// on the URL and does not change it.
|
||||||
|
type EntrypointTotals struct {
|
||||||
|
EntrypointID string `gorm:"type:uuid;primaryKey"`
|
||||||
|
|
||||||
|
LastEventAt time.Time `gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName names the table AddEntrypointTotals updates.
|
||||||
|
func (EntrypointTotals) TableName() string {
|
||||||
|
return "entrypoint_totals"
|
||||||
|
}
|
||||||
|
|
||||||
// AddEventTotals adds each count in add to the webhook's event totals,
|
// AddEventTotals adds each count in add to the webhook's event totals,
|
||||||
// and records add.LastEventAt as when the newest event arrived if it is
|
// and records add.LastEventAt as when the newest event arrived if it is
|
||||||
// set. Call it on the transaction that writes or deletes the events it
|
// set. Call it on the transaction that writes or deletes the events it
|
||||||
@@ -97,3 +112,25 @@ func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddEntrypointTotals records add.LastEventAt as when the newest event
|
||||||
|
// arrived on the URL of the entrypoint add.EntrypointID names, creating
|
||||||
|
// its row the first time. Call it on the transaction that stores the
|
||||||
|
// event.
|
||||||
|
func AddEntrypointTotals(tx *gorm.DB, add EntrypointTotals) error {
|
||||||
|
err := tx.Exec(
|
||||||
|
`INSERT INTO entrypoint_totals (entrypoint_id, last_event_at)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT (entrypoint_id) DO UPDATE SET
|
||||||
|
last_event_at = excluded.last_event_at`,
|
||||||
|
add.EntrypointID, add.LastEventAt,
|
||||||
|
).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"adding to totals of entrypoint %s: %w",
|
||||||
|
add.EntrypointID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ func (w *Webhook) RetainsForever() bool {
|
|||||||
return retainsForever(w.RetentionDays)
|
return retainsForever(w.RetentionDays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetentionCutoff returns the time before which this webhook's events
|
||||||
|
// have expired, as the reaper computes it, and false when the webhook
|
||||||
|
// retains them forever.
|
||||||
|
func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) {
|
||||||
|
return retentionCutoff(now, w.RetentionDays)
|
||||||
|
}
|
||||||
|
|
||||||
// RetentionLabel returns the webhook's retention policy as display
|
// RetentionLabel returns the webhook's retention policy as display
|
||||||
// text, so that no template has to know about the sentinel value.
|
// text, so that no template has to know about the sentinel value.
|
||||||
func (w *Webhook) RetentionLabel() string {
|
func (w *Webhook) RetentionLabel() string {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package database
|
|||||||
// Migrate runs database migrations for the main application database.
|
// Migrate runs database migrations for the main application database.
|
||||||
// Only configuration-tier models are stored in the main database.
|
// Only configuration-tier models are stored in the main database.
|
||||||
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
||||||
// TargetTotals) live in
|
// TargetTotals, EntrypointTotals) live in
|
||||||
// per-webhook dedicated databases managed by WebhookDBManager.
|
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||||
func (d *Database) Migrate() error {
|
func (d *Database) Migrate() error {
|
||||||
return d.db.AutoMigrate(
|
return d.db.AutoMigrate(
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ var ErrSidecarNotRemoved = errors.New(
|
|||||||
// WebhookDBManager manages per-webhook SQLite database files
|
// WebhookDBManager manages per-webhook SQLite database files
|
||||||
// for event storage. Each webhook gets its own dedicated
|
// for event storage. Each webhook gets its own dedicated
|
||||||
// database containing Events, Deliveries, DeliveryResults and the
|
// database containing Events, Deliveries, DeliveryResults and the
|
||||||
// running totals of them (EventTotals, TargetTotals).
|
// running totals of them (EventTotals, TargetTotals, EntrypointTotals).
|
||||||
// Database connections are opened lazily and cached.
|
// Database connections are opened lazily and cached.
|
||||||
type WebhookDBManager struct {
|
type WebhookDBManager struct {
|
||||||
dataDir string
|
dataDir string
|
||||||
@@ -381,7 +381,7 @@ func (m *WebhookDBManager) openDB(
|
|||||||
// Run migrations for event-tier models only
|
// Run migrations for event-tier models only
|
||||||
err = db.AutoMigrate(
|
err = db.AutoMigrate(
|
||||||
&Event{}, &Delivery{}, &DeliveryResult{},
|
&Event{}, &Delivery{}, &DeliveryResult{},
|
||||||
&EventTotals{}, &TargetTotals{},
|
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,6 +16,21 @@ type EntrypointView struct {
|
|||||||
Path string
|
Path string
|
||||||
Description string
|
Description string
|
||||||
Active bool
|
Active bool
|
||||||
|
|
||||||
|
// Events is how many events arrived on the entrypoint's URL within
|
||||||
|
// the webhook's retention period. LastEvent is when the newest
|
||||||
|
// event ever to arrive on it did, relative, and LastEventUTC the
|
||||||
|
// full time; both are empty when none ever did.
|
||||||
|
Events int64
|
||||||
|
LastEvent string
|
||||||
|
LastEventUTC string
|
||||||
|
}
|
||||||
|
|
||||||
|
// entrypointEvents is one entrypoint's count read by
|
||||||
|
// addEntrypointEvents.
|
||||||
|
type entrypointEvents struct {
|
||||||
|
EntrypointID string
|
||||||
|
Events int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEntrypointViews projects entrypoints for rendering.
|
// NewEntrypointViews projects entrypoints for rendering.
|
||||||
@@ -32,3 +52,60 @@ func NewEntrypointViews(
|
|||||||
|
|
||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addEntrypointEvents fills in each view's event figures from the
|
||||||
|
// webhook's event database: when the last event arrived on its URL,
|
||||||
|
// from its EntrypointTotals row, and how many events arrived on it
|
||||||
|
// since the webhook's retention cutoff, counted in one query over the
|
||||||
|
// events' entrypoint_id index. Resubmitted copies did not arrive on
|
||||||
|
// the URL and are left out of both.
|
||||||
|
func addEntrypointEvents(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhook *database.Webhook,
|
||||||
|
views []EntrypointView,
|
||||||
|
now time.Time,
|
||||||
|
) error {
|
||||||
|
ids := make([]string, len(views))
|
||||||
|
byID := make(map[string]*EntrypointView, len(views))
|
||||||
|
|
||||||
|
for i := range views {
|
||||||
|
ids[i] = views[i].ID
|
||||||
|
byID[views[i].ID] = &views[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
var totals []database.EntrypointTotals
|
||||||
|
|
||||||
|
err := webhookDB.Where("entrypoint_id IN ?", ids).Find(&totals).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading entrypoint totals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := webhookDB.Model(&database.Event{}).
|
||||||
|
Select("entrypoint_id, count(*) AS events").
|
||||||
|
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL", ids)
|
||||||
|
|
||||||
|
cutoff, finite := webhook.RetentionCutoff(now)
|
||||||
|
if finite {
|
||||||
|
query = query.Where("created_at >= ?", cutoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
var counts []entrypointEvents
|
||||||
|
|
||||||
|
err = query.Group("entrypoint_id").Find(&counts).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("counting events by entrypoint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range totals {
|
||||||
|
view := byID[row.EntrypointID]
|
||||||
|
view.LastEvent = humanize.Time(row.LastEventAt)
|
||||||
|
view.LastEventUTC =
|
||||||
|
row.LastEventAt.UTC().Format(time.DateTime) + " UTC"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range counts {
|
||||||
|
byID[row.EntrypointID].Events = row.Events
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// entrypointRow returns the part of a rendered webhook page from an
|
||||||
|
// entrypoint's URL to the next entrypoint's, which holds its figures.
|
||||||
|
func entrypointRow(t *testing.T, page, entrypointID string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, row, found := strings.Cut(page, `id="entrypoint-url-`+entrypointID+`"`)
|
||||||
|
require.True(t, found)
|
||||||
|
|
||||||
|
row, _, _ = strings.Cut(row, `id="entrypoint-url-`)
|
||||||
|
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastEventShown matches an entrypoint row's last event arriving at at.
|
||||||
|
func lastEventShown(at time.Time) string {
|
||||||
|
return `Last Event:</span>\s*<span title="` +
|
||||||
|
at.UTC().Format(time.DateTime) + ` UTC">[^<]+</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventsShown matches an entrypoint row's count of n events.
|
||||||
|
func eventsShown(n int) string {
|
||||||
|
return `Events Within Retention:</span>\s*<span>` +
|
||||||
|
strconv.Itoa(n) + `</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_ShowsEntrypointEvents proves each entrypoint
|
||||||
|
// on the webhook page shows its own figures: how many events arrived
|
||||||
|
// through it within the webhook's retention period, leaving out one
|
||||||
|
// older than that, and when the newest arrived, or "never" for an
|
||||||
|
// entrypoint with none.
|
||||||
|
func TestHandleSourceDetail_ShowsEntrypointEvents(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 := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID, Name: "figures", RetentionDays: 7,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
entrypoint := func() *database.Entrypoint {
|
||||||
|
ep := &database.Entrypoint{
|
||||||
|
WebhookID: wh.ID, Path: uuid.New().String(), Active: true,
|
||||||
|
}
|
||||||
|
require.NoError(t,
|
||||||
|
db.DB().Omit(clause.Associations).Create(ep).Error)
|
||||||
|
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
|
||||||
|
// event stores an event that arrived on ep's URL age ago and
|
||||||
|
// records it as ep's last event, as the receiver does.
|
||||||
|
event := func(ep *database.Entrypoint, age time.Duration) time.Time {
|
||||||
|
e := &database.Event{
|
||||||
|
WebhookID: wh.ID,
|
||||||
|
EntrypointID: ep.ID,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
}
|
||||||
|
e.CreatedAt = time.Now().Add(-age)
|
||||||
|
require.NoError(t,
|
||||||
|
webhookDB.Omit(clause.Associations).Create(e).Error)
|
||||||
|
require.NoError(t, database.AddEntrypointTotals(webhookDB,
|
||||||
|
database.EntrypointTotals{
|
||||||
|
EntrypointID: ep.ID, LastEventAt: e.CreatedAt,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return e.CreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
busy, quiet, unused := entrypoint(), entrypoint(), entrypoint()
|
||||||
|
|
||||||
|
event(busy, 8*24*time.Hour) // older than the 7 days kept
|
||||||
|
event(busy, 3*time.Hour)
|
||||||
|
busyLast := event(busy, time.Hour)
|
||||||
|
quietLast := event(quiet, 2*24*time.Hour)
|
||||||
|
|
||||||
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
assert.Regexp(t, lastEventShown(busyLast), entrypointRow(t, body, busy.ID))
|
||||||
|
assert.Regexp(t, eventsShown(2), entrypointRow(t, body, busy.ID))
|
||||||
|
assert.Regexp(t, lastEventShown(quietLast), entrypointRow(t, body, quiet.ID))
|
||||||
|
assert.Regexp(t, eventsShown(1), entrypointRow(t, body, quiet.ID))
|
||||||
|
assert.Regexp(t, `Last Event:</span>\s*<span>never</span>`,
|
||||||
|
entrypointRow(t, body, unused.ID))
|
||||||
|
assert.Regexp(t, eventsShown(0), entrypointRow(t, body, unused.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_EntrypointLastEventSurvivesRetention checks
|
||||||
|
// that once retention has removed every event that arrived on an
|
||||||
|
// entrypoint's URL, the entrypoint still shows when the last one
|
||||||
|
// arrived rather than "never".
|
||||||
|
func TestHandleSourceDetail_EntrypointLastEventSurvivesRetention(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
log *logger.Logger
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID, Name: "swept", RetentionDays: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||||
|
|
||||||
|
ep := seedEntrypoint(t, db, wh.ID)
|
||||||
|
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
|
||||||
|
arrived := events[0].CreatedAt
|
||||||
|
|
||||||
|
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
|
||||||
|
statsPrune(t, db, dbMgr, log, webhookDB)
|
||||||
|
require.Empty(t, listEvents(t, webhookDB))
|
||||||
|
|
||||||
|
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
|
||||||
|
assert.Regexp(t, lastEventShown(arrived), row)
|
||||||
|
assert.Regexp(t, eventsShown(0), row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_ResubmitLeavesEntrypointFigures checks that a
|
||||||
|
// resubmitted copy, which did not arrive on the entrypoint's URL,
|
||||||
|
// changes neither the entrypoint's last event nor its count.
|
||||||
|
func TestHandleSourceDetail_ResubmitLeavesEntrypointFigures(
|
||||||
|
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)
|
||||||
|
ep := seedEntrypoint(t, db, wh.ID)
|
||||||
|
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
|
||||||
|
arrived := events[0].CreatedAt
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther,
|
||||||
|
postResubmit(t, h, sess, wh.ID, events[0].ID).Code)
|
||||||
|
require.Len(t, listEvents(t, webhookDB), 2)
|
||||||
|
|
||||||
|
var totals database.EntrypointTotals
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Take(&totals).Error)
|
||||||
|
assert.True(t, arrived.Equal(totals.LastEventAt))
|
||||||
|
|
||||||
|
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
|
||||||
|
assert.Regexp(t, lastEventShown(arrived), row)
|
||||||
|
assert.Regexp(t, eventsShown(1), row)
|
||||||
|
}
|
||||||
@@ -143,22 +143,20 @@ func (s *Handlers) RenderTemplateForTest(
|
|||||||
// BuildSlackTargetConfigForTest exposes
|
// BuildSlackTargetConfigForTest exposes
|
||||||
// buildSlackTargetConfig for use in the handlers_test package.
|
// buildSlackTargetConfig for use in the handlers_test package.
|
||||||
func (s *Handlers) BuildSlackTargetConfigForTest(
|
func (s *Handlers) BuildSlackTargetConfigForTest(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
targetURL string,
|
targetURL string,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
return s.buildSlackTargetConfig(w, r, targetURL)
|
return s.buildSlackTargetConfig(ctx, targetURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
|
// BuildHTTPTargetConfigForTest exposes buildHTTPTargetConfig
|
||||||
// for use in the handlers_test package, taking the form fields
|
// for use in the handlers_test package, taking the form fields
|
||||||
// an HTTP target's configuration is built from.
|
// an HTTP target's configuration is built from.
|
||||||
func (s *Handlers) BuildHTTPTargetConfigForTest(
|
func (s *Handlers) BuildHTTPTargetConfigForTest(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
targetURL, headers, timeout string,
|
targetURL, headers, timeout string,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
return s.buildHTTPTargetConfig(w, r, targetFormInput{
|
return s.buildHTTPTargetConfig(ctx, targetFormInput{
|
||||||
URL: targetURL,
|
URL: targetURL,
|
||||||
Headers: headers,
|
Headers: headers,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
@@ -168,9 +166,8 @@ func (s *Handlers) BuildHTTPTargetConfigForTest(
|
|||||||
// BuildDatabaseTargetConfigForTest exposes
|
// BuildDatabaseTargetConfigForTest exposes
|
||||||
// buildDatabaseTargetConfig for use in the handlers_test
|
// buildDatabaseTargetConfig for use in the handlers_test
|
||||||
// package.
|
// package.
|
||||||
func (s *Handlers) BuildDatabaseTargetConfigForTest(
|
func BuildDatabaseTargetConfigForTest(
|
||||||
w http.ResponseWriter,
|
|
||||||
expiry string,
|
expiry string,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
return s.buildDatabaseTargetConfig(w, newRequestForTest(), expiry)
|
return buildDatabaseTargetConfig(expiry)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,16 +314,12 @@ func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
|
|||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
|
||||||
context.Background(), http.MethodPost, "/", nil)
|
t.Context(), "http://93.184.216.34/services/T00/B00/xxx",
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
cfg, err := h.BuildSlackTargetConfigForTest(
|
|
||||||
w, req, "http://93.184.216.34/services/T00/B00/xxx",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Empty(t, errMsg)
|
||||||
assert.Contains(t, cfg, "webhookUrl")
|
assert.Contains(t, cfg, "webhookUrl")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,17 +333,13 @@ func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
|
|||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
|
||||||
context.Background(), http.MethodPost, "/", nil)
|
t.Context(), "http://169.254.169.254/latest/meta-data/",
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
cfg, err := h.BuildSlackTargetConfigForTest(
|
|
||||||
w, req, "http://169.254.169.254/latest/meta-data/",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require.Error(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, errMsg, "Invalid target URL")
|
||||||
assert.Empty(t, cfg)
|
assert.Empty(t, cfg)
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderTemplate(t *testing.T) {
|
func TestRenderTemplate(t *testing.T) {
|
||||||
@@ -444,29 +436,22 @@ func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
|||||||
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
// Empty expiry: the keep-forever default, empty config.
|
// Empty expiry: the keep-forever default, empty config.
|
||||||
w := httptest.NewRecorder()
|
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("")
|
||||||
cfg, err := h.BuildDatabaseTargetConfigForTest(w, "")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, errMsg)
|
||||||
assert.Empty(t, cfg)
|
assert.Empty(t, cfg)
|
||||||
|
|
||||||
// Explicit never is stored as config.
|
// Explicit never is stored as config.
|
||||||
w = httptest.NewRecorder()
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never")
|
||||||
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "never")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, errMsg)
|
||||||
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
||||||
|
|
||||||
// A positive duration is stored as config.
|
// A positive duration is stored as config.
|
||||||
w = httptest.NewRecorder()
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h")
|
||||||
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "720h")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, errMsg)
|
||||||
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,22 +460,14 @@ func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var h *handlers.Handlers
|
|
||||||
|
|
||||||
app := newTestApp(t, &h)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
||||||
w := httptest.NewRecorder()
|
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad)
|
||||||
cfg, err := h.BuildDatabaseTargetConfigForTest(w, bad)
|
|
||||||
|
|
||||||
require.Error(t, err, "expiry %q", bad)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, cfg)
|
assert.Contains(
|
||||||
assert.Equal(
|
t, errMsg, "Invalid archive expiry",
|
||||||
t, http.StatusBadRequest, w.Code,
|
"expiry %q should be refused", bad,
|
||||||
"expiry %q should be rejected with 400", bad,
|
|
||||||
)
|
)
|
||||||
|
assert.Empty(t, cfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -38,9 +39,6 @@ type WebhookListItem struct {
|
|||||||
EventsUnreadable bool
|
EventsUnreadable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// errMissingURL signals that a required URL was not provided.
|
|
||||||
var errMissingURL = errors.New("missing URL")
|
|
||||||
|
|
||||||
// parseRetentionDays interprets a retention_days form value. It
|
// parseRetentionDays interprets a retention_days form value. It
|
||||||
// returns the number of days, or, for a value it refuses, the message
|
// returns the number of days, or, for a value it refuses, the message
|
||||||
// the create and edit forms show; the message is empty when the value
|
// the create and edit forms show; the message is empty when the value
|
||||||
@@ -460,15 +458,20 @@ func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderSourceDetail(w, r, webhook)
|
h.renderSourceDetail(w, r, webhook, targetFormInput{}, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderSourceDetail loads and renders a source detail page.
|
// renderSourceDetail loads and renders a source detail page. With a
|
||||||
|
// targetErr, it is the page shown again for a refused add target
|
||||||
|
// form: it answers 400, and the form opens on targetForm's type with
|
||||||
|
// its values and the message.
|
||||||
func (h *Handlers) renderSourceDetail(
|
func (h *Handlers) renderSourceDetail(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
|
targetForm targetFormInput,
|
||||||
|
targetErr string,
|
||||||
) {
|
) {
|
||||||
var entrypoints []database.Entrypoint
|
var entrypoints []database.Entrypoint
|
||||||
|
|
||||||
@@ -482,6 +485,8 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
"webhook_id = ?", webhook.ID,
|
"webhook_id = ?", webhook.ID,
|
||||||
).Find(&targets)
|
).Find(&targets)
|
||||||
|
|
||||||
|
entrypointViews := NewEntrypointViews(entrypoints)
|
||||||
|
|
||||||
var events []RecentEventView
|
var events []RecentEventView
|
||||||
|
|
||||||
if h.dbMgr.DBExists(webhook.ID) {
|
if h.dbMgr.DBExists(webhook.ID) {
|
||||||
@@ -500,6 +505,15 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = addEntrypointEvents(
|
||||||
|
webhookDB, &webhook, entrypointViews, time.Now(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to count entrypoint events", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scheme := "http"
|
scheme := "http"
|
||||||
@@ -520,14 +534,21 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
// Targets are projected to a display-safe view: a
|
// Targets are projected to a display-safe view: a
|
||||||
// target's stored config blob holds a credential, and it
|
// target's stored config blob holds a credential, and it
|
||||||
// must never reach a template.
|
// must never reach a template.
|
||||||
"Entrypoints": NewEntrypointViews(entrypoints),
|
"Entrypoints": entrypointViews,
|
||||||
"Targets": h.targetRows(&webhook, targets),
|
"Targets": h.targetRows(&webhook, targets),
|
||||||
"Events": events,
|
"Events": events,
|
||||||
"BaseURL": baseURL,
|
"BaseURL": baseURL,
|
||||||
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
||||||
|
"TargetForm": targetForm,
|
||||||
|
"TargetError": targetErr,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_detail.html", data)
|
status := http.StatusOK
|
||||||
|
if targetErr != "" {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderTemplateStatus(w, r, "source_detail.html", data, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSourceEdit shows the form to edit a webhook.
|
// HandleSourceEdit shows the form to edit a webhook.
|
||||||
@@ -1518,64 +1539,27 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// processTargetCreate validates and creates a new target.
|
// processTargetCreate validates and creates a new target. A refused
|
||||||
|
// submission shows the webhook page again, with the add target form
|
||||||
|
// open on the chosen type, the values entered, and the reason.
|
||||||
func (h *Handlers) processTargetCreate(
|
func (h *Handlers) processTargetCreate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
) {
|
) {
|
||||||
// The body size cap is enforced by the MaxBodySize middleware,
|
in := targetFormInputFrom(r)
|
||||||
// which runs before CSRF parses the form.
|
|
||||||
//
|
|
||||||
// Every field here is read with PostFormValue, not FormValue.
|
|
||||||
// FormValue falls back to the query string, which would let
|
|
||||||
// `POST /hook/{id}/targets?url=https://hooks.slack.com/...`
|
|
||||||
// configure a target from a value the request line carries — and
|
|
||||||
// the request line, unlike the body, is what logs, proxies,
|
|
||||||
// Referer headers and error trackers record.
|
|
||||||
name := r.PostFormValue("name")
|
|
||||||
targetType := database.TargetType(r.PostFormValue("type"))
|
|
||||||
|
|
||||||
if name == "" {
|
target, errMsg, err := h.newTarget(r.Context(), webhook.ID, in)
|
||||||
http.Error(
|
|
||||||
w, "Name is required", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isValidTargetType(targetType) {
|
|
||||||
http.Error(
|
|
||||||
w, "Invalid target type",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
configJSON, err := h.buildTargetConfig(
|
|
||||||
w, r, targetType, targetFormInputFrom(r),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to encode target config", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// A new target has no stored retry count, so an absent field
|
if errMsg != "" {
|
||||||
// takes the fire-and-forget default. A field the operator filled
|
h.renderSourceDetail(w, r, webhook, in, errMsg)
|
||||||
// in with something invalid is rejected rather than becoming
|
|
||||||
// that default.
|
|
||||||
maxRetries, ok := targetMaxRetries(w, r, 0)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
target := &database.Target{
|
return
|
||||||
WebhookID: webhook.ID,
|
|
||||||
Name: name,
|
|
||||||
Type: targetType,
|
|
||||||
Active: true,
|
|
||||||
Config: configJSON,
|
|
||||||
MaxRetries: maxRetries,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.db.DB().Create(target).Error
|
err = h.db.DB().Create(target).Error
|
||||||
@@ -1591,6 +1575,49 @@ func (h *Handlers) processTargetCreate(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newTarget validates a new target for a webhook and returns the row
|
||||||
|
// to create, or, when it refuses the target, the message the form
|
||||||
|
// shows. An error is the server's fault, not a refusal: the accepted
|
||||||
|
// configuration could not be encoded. Every form that creates a
|
||||||
|
// target goes through here, so they all accept and refuse the same
|
||||||
|
// things.
|
||||||
|
func (h *Handlers) newTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
webhookID string,
|
||||||
|
in targetFormInput,
|
||||||
|
) (*database.Target, string, error) {
|
||||||
|
if in.Name == "" {
|
||||||
|
return nil, "Name is required", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidTargetType(in.Type) {
|
||||||
|
return nil, "Invalid target type", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, errMsg, err := h.buildTargetConfig(ctx, in.Type, in)
|
||||||
|
if err != nil || errMsg != "" {
|
||||||
|
return nil, errMsg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new target has no stored retry count, so an absent field
|
||||||
|
// takes the fire-and-forget default. A field the operator filled
|
||||||
|
// in with something invalid is refused rather than becoming
|
||||||
|
// that default.
|
||||||
|
maxRetries, err := parseMaxRetries(in.MaxRetries, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "Invalid max retries: " + retriesErrorMessage(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &database.Target{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
Name: in.Name,
|
||||||
|
Type: in.Type,
|
||||||
|
Active: true,
|
||||||
|
Config: configJSON,
|
||||||
|
MaxRetries: maxRetries,
|
||||||
|
}, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// isValidTargetType checks whether the target type is supported.
|
// isValidTargetType checks whether the target type is supported.
|
||||||
func isValidTargetType(tt database.TargetType) bool {
|
func isValidTargetType(tt database.TargetType) bool {
|
||||||
switch tt {
|
switch tt {
|
||||||
@@ -1622,11 +1649,16 @@ func pageOrFirst(s string) int {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// targetFormInput carries the raw form values describing a target's
|
// targetFormInput carries the raw values of a target form. Both the
|
||||||
// configuration. Both the create and the edit path fill one and hand
|
// create and the edit path fill one and hand it to buildTargetConfig,
|
||||||
// it to buildTargetConfig, so neither can come to validate a
|
// so neither can come to validate a destination differently from the
|
||||||
// destination differently from the other.
|
// other. A refused add target form is shown again from it.
|
||||||
type targetFormInput struct {
|
type targetFormInput struct {
|
||||||
|
// Name is the target's name.
|
||||||
|
Name string
|
||||||
|
// Type is the type chosen on the add target form. The edit form
|
||||||
|
// has none: a target's stored type decides.
|
||||||
|
Type database.TargetType
|
||||||
// URL is the destination for an HTTP target and the webhook URL
|
// URL is the destination for an HTTP target and the webhook URL
|
||||||
// for a Slack target.
|
// for a Slack target.
|
||||||
URL string
|
URL string
|
||||||
@@ -1635,13 +1667,15 @@ type targetFormInput struct {
|
|||||||
Headers string
|
Headers string
|
||||||
// Timeout is an HTTP target's per-request timeout in seconds.
|
// Timeout is an HTTP target's per-request timeout in seconds.
|
||||||
Timeout string
|
Timeout string
|
||||||
|
// MaxRetries is an HTTP or Slack target's max_retries.
|
||||||
|
MaxRetries string
|
||||||
// Expiry is a database (archive) target's row expiry.
|
// Expiry is a database (archive) target's row expiry.
|
||||||
Expiry string
|
Expiry string
|
||||||
}
|
}
|
||||||
|
|
||||||
// targetFormInputFrom reads the configuration fields from a request
|
// targetFormInputFrom reads a target form from a request body. The
|
||||||
// body. The body size cap is enforced by the MaxBodySize middleware,
|
// body size cap is enforced by the MaxBodySize middleware, which runs
|
||||||
// which runs before CSRF parses the form.
|
// before CSRF parses the form.
|
||||||
//
|
//
|
||||||
// Every field is read with PostFormValue, not FormValue. FormValue
|
// Every field is read with PostFormValue, not FormValue. FormValue
|
||||||
// falls back to the query string, which would let
|
// falls back to the query string, which would let
|
||||||
@@ -1653,38 +1687,38 @@ type targetFormInput struct {
|
|||||||
// tokens.
|
// tokens.
|
||||||
func targetFormInputFrom(r *http.Request) targetFormInput {
|
func targetFormInputFrom(r *http.Request) targetFormInput {
|
||||||
return targetFormInput{
|
return targetFormInput{
|
||||||
URL: r.PostFormValue("url"),
|
Name: r.PostFormValue("name"),
|
||||||
Headers: r.PostFormValue("headers"),
|
Type: database.TargetType(r.PostFormValue("type")),
|
||||||
Timeout: r.PostFormValue("timeout"),
|
URL: r.PostFormValue("url"),
|
||||||
Expiry: r.PostFormValue("expiry"),
|
Headers: r.PostFormValue("headers"),
|
||||||
|
Timeout: r.PostFormValue("timeout"),
|
||||||
|
MaxRetries: r.PostFormValue("max_retries"),
|
||||||
|
Expiry: r.PostFormValue("expiry"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTargetConfig builds the JSON config string for a target from
|
// buildTargetConfig builds the JSON config string for a target from
|
||||||
// the submitted form values, writing its own 4xx response on
|
// the submitted form values, or returns the message the form shows
|
||||||
// rejection. Which fields of in apply depends on the target type.
|
// for a value it refuses. An error is the server's fault, not a
|
||||||
|
// refusal: the accepted configuration could not be encoded. Which
|
||||||
|
// fields of in apply depends on the target type; a type without a URL
|
||||||
|
// ignores any URL submitted.
|
||||||
func (h *Handlers) buildTargetConfig(
|
func (h *Handlers) buildTargetConfig(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
targetType database.TargetType,
|
targetType database.TargetType,
|
||||||
in targetFormInput,
|
in targetFormInput,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
switch targetType {
|
switch targetType {
|
||||||
case database.TargetTypeHTTP:
|
case database.TargetTypeHTTP:
|
||||||
return h.buildHTTPTargetConfig(w, r, in)
|
return h.buildHTTPTargetConfig(ctx, in)
|
||||||
case database.TargetTypeSlack:
|
case database.TargetTypeSlack:
|
||||||
return h.buildSlackTargetConfig(w, r, in.URL)
|
return h.buildSlackTargetConfig(ctx, in.URL)
|
||||||
case database.TargetTypeDatabase:
|
case database.TargetTypeDatabase:
|
||||||
return h.buildDatabaseTargetConfig(w, r, in.Expiry)
|
return buildDatabaseTargetConfig(in.Expiry)
|
||||||
case database.TargetTypeLog:
|
case database.TargetTypeLog:
|
||||||
return "", nil
|
return "", "", nil
|
||||||
default:
|
default:
|
||||||
http.Error(
|
return "", "Invalid target type", nil
|
||||||
w, "Invalid target type",
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", errMissingURL
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1692,92 +1726,73 @@ func (h *Handlers) buildTargetConfig(
|
|||||||
// SSRF-validated destination plus the optional headers and timeout
|
// SSRF-validated destination plus the optional headers and timeout
|
||||||
// the delivery path honours.
|
// the delivery path honours.
|
||||||
func (h *Handlers) buildHTTPTargetConfig(
|
func (h *Handlers) buildHTTPTargetConfig(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
in targetFormInput,
|
in targetFormInput,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
err := h.validateTargetURL(
|
errMsg := h.validateTargetURL(
|
||||||
w, r, in.URL, "URL is required for HTTP targets",
|
ctx, in.URL, "URL is required for HTTP targets",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if errMsg != "" {
|
||||||
return "", err
|
return "", errMsg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
headers, err := delivery.ParseTargetHeaders(in.Headers)
|
headers, err := delivery.ParseTargetHeaders(in.Headers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
return "", fmt.Sprintf("Invalid headers: %v", err), nil
|
||||||
w,
|
|
||||||
"Invalid headers: "+err.Error(),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout, err := delivery.ParseTargetTimeout(in.Timeout)
|
timeout, err := delivery.ParseTargetTimeout(in.Timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
return "", fmt.Sprintf("Invalid timeout: %v", err), nil
|
||||||
w,
|
|
||||||
"Invalid timeout: "+err.Error(),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(w, r, delivery.HTTPTargetConfig{
|
configJSON, err := marshalTargetConfig(delivery.HTTPTargetConfig{
|
||||||
URL: in.URL,
|
URL: in.URL,
|
||||||
Headers: headers,
|
Headers: headers,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return configJSON, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSlackTargetConfig builds config JSON for a Slack target,
|
// buildSlackTargetConfig builds config JSON for a Slack target,
|
||||||
// whose whole configuration is one SSRF-validated webhook URL.
|
// whose whole configuration is one SSRF-validated webhook URL.
|
||||||
func (h *Handlers) buildSlackTargetConfig(
|
func (h *Handlers) buildSlackTargetConfig(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
targetURL string,
|
targetURL string,
|
||||||
) (string, error) {
|
) (string, string, error) {
|
||||||
err := h.validateTargetURL(
|
errMsg := h.validateTargetURL(
|
||||||
w, r, targetURL,
|
ctx, targetURL,
|
||||||
"Webhook URL is required for Slack targets",
|
"Webhook URL is required for Slack targets",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if errMsg != "" {
|
||||||
return "", err
|
return "", errMsg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(w, r, delivery.SlackTargetConfig{
|
configJSON, err := marshalTargetConfig(delivery.SlackTargetConfig{
|
||||||
WebhookURL: targetURL,
|
WebhookURL: targetURL,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return configJSON, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateTargetURL rejects an empty or SSRF-blocked destination,
|
// validateTargetURL refuses an empty or SSRF-blocked destination,
|
||||||
// writing the 400 itself. missingMsg is the error shown when no URL
|
// returning the message the form shows, or "" when the destination
|
||||||
// is given.
|
// is accepted. missingMsg is the message for no URL at all.
|
||||||
//
|
//
|
||||||
// It is the single point at which a user-supplied destination enters
|
// It is the single point at which a user-supplied destination enters
|
||||||
// the SSRF guard, on create and on edit alike. An edit path that
|
// the SSRF guard, on create and on edit alike. An edit path that
|
||||||
// reached storage without passing through here would reopen the hole
|
// reached storage without passing through here would reopen the hole
|
||||||
// the guard closes.
|
// the guard closes.
|
||||||
func (h *Handlers) validateTargetURL(
|
func (h *Handlers) validateTargetURL(
|
||||||
w http.ResponseWriter,
|
ctx context.Context,
|
||||||
r *http.Request,
|
|
||||||
targetURL, missingMsg string,
|
targetURL, missingMsg string,
|
||||||
) error {
|
) string {
|
||||||
if targetURL == "" {
|
if targetURL == "" {
|
||||||
http.Error(
|
return missingMsg
|
||||||
w,
|
|
||||||
missingMsg,
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return errMissingURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.ssrf.ValidateTargetURL(
|
err := h.ssrf.ValidateTargetURL(ctx, targetURL)
|
||||||
r.Context(), targetURL,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The submitted URL can be a credential (a Slack
|
// The submitted URL can be a credential (a Slack
|
||||||
// incoming webhook URL is a bearer token), so the log
|
// incoming webhook URL is a bearer token), so the log
|
||||||
@@ -1803,25 +1818,16 @@ func (h *Handlers) validateTargetURL(
|
|||||||
"egress to your own network\" in the README)."
|
"egress to your own network\" in the README)."
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Error(w, msg, http.StatusBadRequest)
|
return msg
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// marshalTargetConfig serialises a target configuration for storage,
|
// marshalTargetConfig serialises a target configuration for storage.
|
||||||
// writing a 500 itself if it cannot.
|
func marshalTargetConfig(cfg any) (string, error) {
|
||||||
func (h *Handlers) marshalTargetConfig(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
cfg any,
|
|
||||||
) (string, error) {
|
|
||||||
configBytes, err := json.Marshal(cfg)
|
configBytes, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.serverError(w, r, "failed to encode target config", err)
|
|
||||||
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1829,35 +1835,24 @@ func (h *Handlers) marshalTargetConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildDatabaseTargetConfig builds config JSON for a database
|
// buildDatabaseTargetConfig builds config JSON for a database
|
||||||
// (archive) target. The optional expiry (a form value read by
|
// (archive) target. The optional expiry is validated here, at
|
||||||
// the caller, which bounds the request body) is validated here,
|
// creation time, so an unparseable value is refused instead of
|
||||||
// at creation time, so an unparseable value is rejected with a
|
// failing every subsequent delivery. An empty expiry yields an
|
||||||
// 400 instead of failing every subsequent delivery. An empty
|
// empty config (the keep-forever default).
|
||||||
// expiry yields an empty config (the keep-forever default).
|
func buildDatabaseTargetConfig(expiry string) (string, string, error) {
|
||||||
func (h *Handlers) buildDatabaseTargetConfig(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
expiry string,
|
|
||||||
) (string, error) {
|
|
||||||
expiry = strings.TrimSpace(expiry)
|
expiry = strings.TrimSpace(expiry)
|
||||||
if expiry == "" {
|
if expiry == "" {
|
||||||
return "", nil
|
return "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := delivery.ValidateArchiveExpiry(expiry)
|
err := delivery.ValidateArchiveExpiry(expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
return "", fmt.Sprintf("Invalid archive expiry: %v", err), nil
|
||||||
w,
|
|
||||||
"Invalid archive expiry: "+err.Error(),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.marshalTargetConfig(
|
configJSON, err := marshalTargetConfig(map[string]any{"expiry": expiry})
|
||||||
w, r, map[string]any{"expiry": expiry},
|
|
||||||
)
|
return configJSON, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleEntrypointDelete handles deleting an entrypoint.
|
// HandleEntrypointDelete handles deleting an entrypoint.
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHandleTargetCreate_EveryType adds a target of each type. Each
|
||||||
|
// submission carries a url: only the http and slack types store one.
|
||||||
|
func TestHandleTargetCreate_EveryType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
// fields is the rest of each submission, as a query string.
|
||||||
|
cases := []struct {
|
||||||
|
targetType database.TargetType
|
||||||
|
fields string
|
||||||
|
wantConfig string
|
||||||
|
wantRetries int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
database.TargetTypeHTTP, "timeout=12&max_retries=3",
|
||||||
|
`{"url":"` + editOriginalURL + `","timeout":12}`, 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
database.TargetTypeSlack, "max_retries=4",
|
||||||
|
`{"webhookUrl":"` + editOriginalURL + `"}`, 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
database.TargetTypeDatabase, "expiry=720h",
|
||||||
|
`{"expiry":"720h"}`, 0,
|
||||||
|
},
|
||||||
|
{database.TargetTypeLog, "", "", 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(string(tc.targetType), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
form, err := url.ParseQuery(tc.fields)
|
||||||
|
require.NoError(t, err)
|
||||||
|
form.Set("name", "every-type")
|
||||||
|
form.Set("type", string(tc.targetType))
|
||||||
|
form.Set("url", editOriginalURL)
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost,
|
||||||
|
"/hook/"+webhook.ID+"/targets", form,
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
assert.Equal(t, tc.targetType, targets[0].Type)
|
||||||
|
assert.Equal(t, tc.wantRetries, targets[0].MaxRetries)
|
||||||
|
|
||||||
|
if tc.wantConfig == "" {
|
||||||
|
assert.Empty(t, targets[0].Config)
|
||||||
|
} else {
|
||||||
|
assert.JSONEq(t, tc.wantConfig, targets[0].Config)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleTargetCreate_RefusedFormComesBack refuses a target of each
|
||||||
|
// type and checks that the webhook page comes back with the add target
|
||||||
|
// form open on that type, the values entered, and the reason.
|
||||||
|
func TestHandleTargetCreate_RefusedFormComesBack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
// fields is what the operator typed, as a query string.
|
||||||
|
cases := []struct {
|
||||||
|
targetType database.TargetType
|
||||||
|
fields string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
database.TargetTypeHTTP,
|
||||||
|
"name=private&url=" + editBlockedURL +
|
||||||
|
"&timeout=12&max_retries=3",
|
||||||
|
"Invalid target URL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
database.TargetTypeSlack, "name=no-url&max_retries=4",
|
||||||
|
"Webhook URL is required for Slack targets",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
database.TargetTypeDatabase, "name=archive&expiry=7d",
|
||||||
|
"Invalid archive expiry",
|
||||||
|
},
|
||||||
|
{database.TargetTypeLog, "name=", "Name is required"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(string(tc.targetType), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
typed, err := url.ParseQuery(tc.fields)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("type", string(tc.targetType))
|
||||||
|
|
||||||
|
for field := range typed {
|
||||||
|
form.Set(field, typed.Get(field))
|
||||||
|
}
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost,
|
||||||
|
"/hook/"+webhook.ID+"/targets", form,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
|
||||||
|
page := w.Body.String()
|
||||||
|
assert.Contains(
|
||||||
|
t, page, `data-type="`+string(tc.targetType)+`"`,
|
||||||
|
)
|
||||||
|
assert.Contains(t, page, html.EscapeString(tc.reason))
|
||||||
|
|
||||||
|
// Each value comes back in a data attribute of the targets
|
||||||
|
// section named after its field (max_retries as
|
||||||
|
// data-max-retries), except url, which comes back in
|
||||||
|
// data-destination; templates/source_detail.html says why.
|
||||||
|
for field := range typed {
|
||||||
|
attr := "data-" + strings.ReplaceAll(field, "_", "-")
|
||||||
|
if field == "url" {
|
||||||
|
attr = "data-destination"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, page, attr+`="`+
|
||||||
|
html.EscapeString(typed.Get(field))+`"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,18 +120,23 @@ func (h *Handlers) applyTargetEdit(
|
|||||||
) {
|
) {
|
||||||
name := r.PostFormValue("name")
|
name := r.PostFormValue("name")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
http.Error(
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||||
w, "Name is required", http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
configJSON, err := h.buildTargetConfig(
|
configJSON, errMsg, err := h.buildTargetConfig(
|
||||||
w, r, target.Type, targetFormInputFrom(r),
|
r.Context(), target.Type, targetFormInputFrom(r),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// buildTargetConfig has already written the response.
|
h.serverError(w, r, "failed to encode target config", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg != "" {
|
||||||
|
http.Error(w, errMsg, http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -43,12 +44,16 @@ func TestTargetRefusal_PrivateDestinationSaysHowToAllowIt(
|
|||||||
form.Set("type", string(targetType))
|
form.Set("type", string(targetType))
|
||||||
form.Set("url", editBlockedURL)
|
form.Set("url", editBlockedURL)
|
||||||
|
|
||||||
|
// A refused add shows the webhook page again, where
|
||||||
|
// the hint is HTML-escaped; a refused edit answers in
|
||||||
|
// plain text.
|
||||||
added := serveTarget(
|
added := serveTarget(
|
||||||
env, http.MethodPost, targetsPath, form,
|
env, http.MethodPost, targetsPath, form,
|
||||||
)
|
)
|
||||||
assert.Equal(t, http.StatusBadRequest, added.Code)
|
assert.Equal(t, http.StatusBadRequest, added.Code)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, added.Body.String(), privateRefusalHint,
|
t, added.Body.String(),
|
||||||
|
html.EscapeString(privateRefusalHint),
|
||||||
)
|
)
|
||||||
|
|
||||||
form.Set("url", editOriginalURL)
|
form.Set("url", editOriginalURL)
|
||||||
|
|||||||
@@ -90,13 +90,14 @@ func retriesErrorMessage(err error) string {
|
|||||||
", or 0 for fire-and-forget"
|
", or 0 for fire-and-forget"
|
||||||
}
|
}
|
||||||
|
|
||||||
// targetMaxRetries reads and validates max_retries from a target form
|
// targetMaxRetries reads and validates max_retries from a target edit
|
||||||
// submission, answering the request with a 400 and reporting false
|
// submission, answering the request with a 400 and reporting false
|
||||||
// when the value is set but invalid.
|
// when the value is set but invalid.
|
||||||
//
|
//
|
||||||
// Both the create and the edit path go through here, so the two
|
// It and the create path (newTarget) both use parseMaxRetries and
|
||||||
// cannot come to disagree about what a valid retry count is. The
|
// retriesErrorMessage, so the two cannot come to disagree about what a
|
||||||
// wording matches the timeout control on the same submission.
|
// valid retry count is. The wording matches the timeout control on
|
||||||
|
// the same submission.
|
||||||
func targetMaxRetries(
|
func targetMaxRetries(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
|
|||||||
@@ -326,6 +326,19 @@ func (h *Handlers) createAndFanOut(
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A resubmitted copy did not arrive on its entrypoint's URL, so it
|
||||||
|
// leaves the entrypoint's last event as it is.
|
||||||
|
if src.ResubmittedFromID == nil {
|
||||||
|
err = database.AddEntrypointTotals(tx, database.EntrypointTotals{
|
||||||
|
EntrypointID: event.EntrypointID, LastEventAt: event.CreatedAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = tx.Commit().Error
|
err = tx.Commit().Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf(
|
return nil, nil, fmt.Errorf(
|
||||||
|
|||||||
@@ -90,12 +90,43 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
|
|
||||||
page := srv.URL + "/hook/" + webhook.ID
|
page := srv.URL + "/hook/" + webhook.ID
|
||||||
|
|
||||||
checkAddForms(ctx, t, page)
|
checkAddEntrypoint(ctx, t, page)
|
||||||
checkTargetType(ctx, t, page+"/events")
|
|
||||||
|
// Each target type, with the fields its add target form submits, in
|
||||||
|
// page order. Only http and slack have a url field.
|
||||||
|
targetTypes := []struct {
|
||||||
|
name string
|
||||||
|
fields string
|
||||||
|
values map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"http", "csrf_token name type url headers timeout max_retries",
|
||||||
|
map[string]string{"url": publicTargetURL},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slack", "csrf_token name type url max_retries",
|
||||||
|
map[string]string{"url": publicTargetURL},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"database", "csrf_token name type expiry",
|
||||||
|
map[string]string{"expiry": "720h"},
|
||||||
|
},
|
||||||
|
{"log", "csrf_token name type", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range targetTypes {
|
||||||
|
checkAddTarget(
|
||||||
|
ctx, t, page, tt.name, strings.Fields(tt.fields), tt.values,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkRefusedTarget(ctx, t, page)
|
||||||
checkCopy(ctx, t, page)
|
checkCopy(ctx, t, page)
|
||||||
checkEntrypointEdit(ctx, t, page, page+"/events")
|
checkEntrypointEdit(ctx, t, page, page+"/events")
|
||||||
checkRecentEvents(ctx, t, page)
|
checkRecentEvents(ctx, t, page)
|
||||||
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
||||||
|
checkEventSelection(ctx, t, page+"/events", event.ID)
|
||||||
|
checkEventKeyboard(ctx, t, page+"/events", event.ID)
|
||||||
checkMobileMenu(ctx, t, page)
|
checkMobileMenu(ctx, t, page)
|
||||||
|
|
||||||
assert.Empty(t, problems(), "the browser reported problems")
|
assert.Empty(t, problems(), "the browser reported problems")
|
||||||
@@ -236,115 +267,194 @@ func click(ctx context.Context, t *testing.T, xpath string) {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkAddForms loads a webhook page and checks that each section's add
|
// checkAddEntrypoint loads a webhook page and checks that the add
|
||||||
// form stays hidden until the Add button beside its heading is clicked.
|
// entrypoint form stays hidden until the Add button beside its heading
|
||||||
// The click looks for a button element there, so it also checks that
|
// is clicked. The click looks for a button element there, so it also
|
||||||
// Add is one.
|
// checks that Add is one.
|
||||||
func checkAddForms(ctx context.Context, t *testing.T, url string) {
|
func checkAddEntrypoint(ctx context.Context, t *testing.T, url string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
form := `form[action$="/entrypoints"]`
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
|
assert.True(t, hidden(ctx, form),
|
||||||
|
"the add entrypoint form shows before Add is clicked")
|
||||||
|
|
||||||
|
click(ctx, t, `//h2[text()="Entrypoints"]/following-sibling::button`)
|
||||||
|
|
||||||
|
assert.True(t, shown(ctx, form),
|
||||||
|
"the add entrypoint form stays hidden when Add is clicked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicTargetURL is a destination the server accepts for an http or
|
||||||
|
// slack target. It is a literal public address, so accepting it needs
|
||||||
|
// no DNS.
|
||||||
|
const publicTargetURL = "https://93.184.216.34/hook"
|
||||||
|
|
||||||
|
// The parts of the targets section's add target form the checks below
|
||||||
|
// find and click. Add is the button beside the Targets heading; each
|
||||||
|
// Cancel is found from the button beside it, since both are on the
|
||||||
|
// page at once.
|
||||||
|
const (
|
||||||
|
addTarget = `//h2[text()="Targets"]/following-sibling::button`
|
||||||
|
typeSelect = `//select[@aria-label="Target type"]`
|
||||||
|
nextButton = `//button[text()="Next"]`
|
||||||
|
cancelChoice = nextButton + `/following-sibling::button[text()="Cancel"]`
|
||||||
|
saveButton = `//form[contains(@action, "/targets")]//button[text()="Save"]`
|
||||||
|
cancelFields = saveButton + `/following-sibling::button[text()="Cancel"]`
|
||||||
|
targetName = `form[action$="/targets"] input[name="name"]`
|
||||||
|
submittedKeys = `[...new FormData(
|
||||||
|
document.querySelector('form[action$="/targets"]')).keys()]`
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkAddTarget loads a webhook page and walks the add target form for
|
||||||
|
// one target type. The form shows nothing until Add is clicked; Add
|
||||||
|
// shows only the type choice; Cancel there closes it; Next shows the
|
||||||
|
// type's own fields in place of the choice, and the form then submits
|
||||||
|
// exactly fields, so a field another type uses, such as url, is absent;
|
||||||
|
// Cancel closes it again. It then adds a target of the type, filling in
|
||||||
|
// values, and checks that the section lists it with that type.
|
||||||
|
func checkAddTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
t *testing.T,
|
||||||
|
url, targetType string,
|
||||||
|
fields []string,
|
||||||
|
values map[string]string,
|
||||||
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
sections := []struct{ heading, form string }{
|
assert.Truef(t, hidden(ctx, typeSelect),
|
||||||
{"Entrypoints", `form[action$="/entrypoints"]`},
|
"%s: the type choice shows before Add is clicked", targetType)
|
||||||
{"Targets", `form[action$="/targets"]`},
|
assert.Truef(t, hidden(ctx, targetName),
|
||||||
|
"%s: the fields show before Add is clicked", targetType)
|
||||||
|
|
||||||
|
click(ctx, t, addTarget)
|
||||||
|
assert.Truef(t, shown(ctx, typeSelect),
|
||||||
|
"%s: Add does not show the type choice", targetType)
|
||||||
|
assert.Truef(t, hidden(ctx, targetName),
|
||||||
|
"%s: Add shows the fields before Next", targetType)
|
||||||
|
|
||||||
|
click(ctx, t, cancelChoice)
|
||||||
|
assert.Truef(t, hidden(ctx, typeSelect),
|
||||||
|
"%s: Cancel does not close the type choice", targetType)
|
||||||
|
|
||||||
|
chooseTargetType(ctx, t, targetType)
|
||||||
|
|
||||||
|
var submitted []string
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, chromedp.Evaluate(submittedKeys, &submitted),
|
||||||
|
))
|
||||||
|
assert.Equalf(t, fields, submitted,
|
||||||
|
"%s: the form does not submit exactly the type's fields", targetType)
|
||||||
|
|
||||||
|
click(ctx, t, cancelFields)
|
||||||
|
assert.Truef(t, hidden(ctx, targetName),
|
||||||
|
"%s: Cancel does not close the fields", targetType)
|
||||||
|
assert.Truef(t, shown(ctx, addTarget),
|
||||||
|
"%s: Add does not come back after Cancel", targetType)
|
||||||
|
|
||||||
|
name := "added-" + targetType
|
||||||
|
|
||||||
|
chooseTargetType(ctx, t, targetType)
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, chromedp.SetValue(targetName, name, chromedp.ByQuery),
|
||||||
|
))
|
||||||
|
|
||||||
|
for field, value := range values {
|
||||||
|
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
||||||
|
`form[action$="/targets"] [name="`+field+`"]`, value,
|
||||||
|
chromedp.ByQuery,
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range sections {
|
click(ctx, t, saveButton)
|
||||||
assert.Truef(
|
assert.Truef(t, shown(ctx, `//span[text()="`+name+
|
||||||
t, hidden(ctx, s.form),
|
`"]/following-sibling::div/span[text()="`+targetType+`"]`),
|
||||||
"%s: the add form shows before Add is clicked", s.heading,
|
"%s: the added target is not listed with its type", targetType)
|
||||||
)
|
|
||||||
|
|
||||||
click(ctx, t, `//h2[text()="`+s.heading+
|
|
||||||
`"]/following-sibling::button`)
|
|
||||||
|
|
||||||
assert.Truef(
|
|
||||||
t, shown(ctx, s.form),
|
|
||||||
"%s: the add form stays hidden when Add is clicked", s.heading,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkTargetType chooses Slack in the open add target form and checks
|
// chooseTargetType clicks Add, picks targetType and clicks Next, and
|
||||||
// what the form would then submit: one url field, the Slack one, and
|
// checks that the type's fields then show in place of the type choice.
|
||||||
// not the HTTP url, headers or timeout, which are hidden and disabled.
|
func chooseTargetType(ctx context.Context, t *testing.T, targetType string) {
|
||||||
//
|
t.Helper()
|
||||||
// It then opens the page at elsewhere and goes back. The browser loads
|
|
||||||
// the webhook page again and restores the form as it was left, Slack
|
click(ctx, t, addTarget)
|
||||||
// chosen, without a change event; the form must again show and submit
|
require.NoError(t, chromedp.Run(
|
||||||
// Slack's fields, not the HTTP ones.
|
ctx, chromedp.SetValue(typeSelect, targetType, chromedp.BySearch),
|
||||||
func checkTargetType(ctx context.Context, t *testing.T, elsewhere string) {
|
))
|
||||||
|
click(ctx, t, nextButton)
|
||||||
|
|
||||||
|
assert.Truef(t, shown(ctx, targetName),
|
||||||
|
"%s: Next does not show the fields", targetType)
|
||||||
|
assert.Truef(t, hidden(ctx, typeSelect),
|
||||||
|
"%s: Next leaves the type choice showing", targetType)
|
||||||
|
assert.Truef(t, hidden(ctx, addTarget),
|
||||||
|
"%s: Add still shows while the form is open", targetType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRefusedTarget submits an http target the server refuses, a
|
||||||
|
// loopback destination, and checks that the page comes back with the
|
||||||
|
// form open on the http fields, the values entered and the reason, and
|
||||||
|
// that after Cancel the next Add starts with an empty form and no
|
||||||
|
// reason.
|
||||||
|
func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
chooseSlack = `(() => {
|
refusedURL = "http://127.0.0.1/hook"
|
||||||
const type = document.querySelector('select[name="type"]');
|
urlField = `form[action$="/targets"] input[name="url"]`
|
||||||
type.value = "slack";
|
reason = `//div[@class="alert-error"]`
|
||||||
type.dispatchEvent(new Event("change"));
|
|
||||||
})()`
|
|
||||||
chosen = `document.querySelector('select[name="type"]').value`
|
|
||||||
howLoaded = `performance.getEntriesByType("navigation")[0].type`
|
|
||||||
submitted = `[...new FormData(
|
|
||||||
document.querySelector('form[action$="/targets"]')).keys()]`
|
|
||||||
slackURL = `input[placeholder^="https://hooks.slack.com/"]`
|
|
||||||
httpURL = `input[placeholder="https://example.com/webhook"]`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
slackFields := strings.Fields("csrf_token name type max_retries url")
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
var fields []string
|
chooseTargetType(ctx, t, "http")
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
chromedp.SetValue(targetName, "refused", chromedp.ByQuery),
|
||||||
|
chromedp.SetValue(urlField, refusedURL, chromedp.ByQuery),
|
||||||
|
))
|
||||||
|
|
||||||
|
click(ctx, t, saveButton)
|
||||||
|
|
||||||
|
assert.True(t, shown(ctx, reason),
|
||||||
|
"a refused target does not show the reason")
|
||||||
|
|
||||||
|
var name, typed string
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx,
|
ctx,
|
||||||
chromedp.Evaluate(chooseSlack, nil),
|
chromedp.Value(targetName, &name, chromedp.ByQuery),
|
||||||
chromedp.Evaluate(submitted, &fields),
|
chromedp.Value(urlField, &typed, chromedp.ByQuery),
|
||||||
))
|
))
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(t, "refused", name,
|
||||||
t, slackFields, fields,
|
"a refused target does not keep the name entered")
|
||||||
"with Slack chosen, the HTTP fields must not be submitted",
|
assert.Equal(t, refusedURL, typed,
|
||||||
)
|
"a refused target does not keep the url entered")
|
||||||
|
assert.True(t, shown(ctx, targetName),
|
||||||
|
"a refused target does not come back with the form open")
|
||||||
|
assert.True(t, hidden(ctx, typeSelect),
|
||||||
|
"a refused target comes back on the type choice")
|
||||||
|
|
||||||
var loaded, restored string
|
click(ctx, t, cancelFields)
|
||||||
|
chooseTargetType(ctx, t, "http")
|
||||||
|
|
||||||
|
assert.True(t, hidden(ctx, reason),
|
||||||
|
"after Cancel, the next Add still shows the reason")
|
||||||
|
|
||||||
// Going back waits for the load event, after which the browser has
|
|
||||||
// restored the form.
|
|
||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx,
|
ctx,
|
||||||
loadPage(elsewhere),
|
chromedp.Value(targetName, &name, chromedp.ByQuery),
|
||||||
chromedp.NavigateBack(),
|
chromedp.Value(urlField, &typed, chromedp.ByQuery),
|
||||||
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
|
||||||
chromedp.Evaluate(howLoaded, &loaded),
|
|
||||||
chromedp.Evaluate(chosen, &restored),
|
|
||||||
))
|
))
|
||||||
|
|
||||||
// A page the browser kept in memory and showed again as it was
|
assert.Empty(t, name, "after Cancel, the next Add keeps the name entered")
|
||||||
// would prove nothing here.
|
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
|
||||||
require.Equal(
|
|
||||||
t, "back_forward", loaded,
|
|
||||||
"going back, the browser did not load the page again",
|
|
||||||
)
|
|
||||||
require.Equal(
|
|
||||||
t, "slack", restored,
|
|
||||||
"going back, the browser did not restore the chosen type",
|
|
||||||
)
|
|
||||||
|
|
||||||
click(ctx, t, `//h2[text()="Targets"]/following-sibling::button`)
|
|
||||||
|
|
||||||
assert.True(t, shown(ctx, slackURL),
|
|
||||||
"going back with Slack chosen, the Slack fields are not shown")
|
|
||||||
assert.True(t, hidden(ctx, httpURL),
|
|
||||||
"going back with Slack chosen, the HTTP fields are shown")
|
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
|
||||||
ctx, chromedp.Evaluate(submitted, &fields),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, slackFields, fields,
|
|
||||||
"going back with Slack chosen, the HTTP fields must not be submitted",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCopy loads a webhook page and checks that the Copy control beside
|
// checkCopy loads a webhook page and checks that the Copy control beside
|
||||||
@@ -384,9 +494,13 @@ func checkEntrypointEdit(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
// Cancel and Save are found inside the edit form, since the add
|
||||||
|
// target form has buttons of the same names.
|
||||||
const (
|
const (
|
||||||
editForm = `form[action$="/edit"]`
|
editForm = `form[action$="/edit"]`
|
||||||
input = editForm + ` input[name="description"]`
|
input = editForm + ` input[name="description"]`
|
||||||
|
cancelEdit = `//form[contains(@action, "/edit")]/button[text()="Cancel"]`
|
||||||
|
saveEdit = `//form[contains(@action, "/edit")]/button[text()="Save"]`
|
||||||
description = `//span[text()="Entrypoint"]`
|
description = `//span[text()="Entrypoint"]`
|
||||||
edit = `//button[text()="Edit"]`
|
edit = `//button[text()="Edit"]`
|
||||||
)
|
)
|
||||||
@@ -407,7 +521,7 @@ func checkEntrypointEdit(
|
|||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx, chromedp.SendKeys(input, "draft", chromedp.ByQuery),
|
ctx, chromedp.SendKeys(input, "draft", chromedp.ByQuery),
|
||||||
))
|
))
|
||||||
click(ctx, t, `//button[text()="Cancel"]`)
|
click(ctx, t, cancelEdit)
|
||||||
assert.True(t, hidden(ctx, editForm),
|
assert.True(t, hidden(ctx, editForm),
|
||||||
"clicking Cancel does not hide the edit form")
|
"clicking Cancel does not hide the edit form")
|
||||||
assert.True(t, shown(ctx, description),
|
assert.True(t, shown(ctx, description),
|
||||||
@@ -449,7 +563,7 @@ func checkEntrypointEdit(
|
|||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery),
|
ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery),
|
||||||
))
|
))
|
||||||
click(ctx, t, `//button[text()="Save"]`)
|
click(ctx, t, saveEdit)
|
||||||
|
|
||||||
assert.True(t, shown(ctx, `//span[text()="Billing sender"]`),
|
assert.True(t, shown(ctx, `//span[text()="Billing sender"]`),
|
||||||
"saving the edit form does not change the description")
|
"saving the edit form does not change the description")
|
||||||
@@ -498,21 +612,23 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
|
|||||||
// checkEventLog loads the event log and checks an event's row. Clicking
|
// checkEventLog loads the event log and checks an event's row. Clicking
|
||||||
// its ID expands the event, and in there clicking its delivery shows the
|
// its ID expands the event, and in there clicking its delivery shows the
|
||||||
// delivery's attempts and clicking again hides them. Clicking the row's
|
// delivery's attempts and clicking again hides them. Clicking the row's
|
||||||
// caret collapses the event. Selecting the ID with the mouse leaves the
|
// caret collapses the event, clicking it again expands it, and clicking
|
||||||
// event collapsed, and the caret's next click expands it. Clicking the
|
// the ID again collapses it. While the event is expanded the row says so
|
||||||
// ID again collapses it. From the keyboard, Enter on the row expands
|
// and its caret is turned up, and while it is collapsed neither.
|
||||||
// the event and Space collapses it.
|
|
||||||
func checkEventLog(
|
func checkEventLog(
|
||||||
ctx context.Context, t *testing.T, url, eventID, targetName string,
|
ctx context.Context, t *testing.T, url, eventID, targetName string,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// The event's row shows its ID and ends with its caret, and its
|
// The event's row shows its ID and ends with its caret, which turns
|
||||||
// Resubmit form is in the part that expands. The delivery's row
|
// up with Tailwind's rotate-180 class, and its Resubmit form is in
|
||||||
// there shows the target's name.
|
// the part that expands. The delivery's row there shows the target's
|
||||||
|
// name.
|
||||||
id := `//span[text()="` + eventID + `"]`
|
id := `//span[text()="` + eventID + `"]`
|
||||||
row := id + `/ancestor::div[@role="button"]`
|
row := id + `/ancestor::div[@role="button"]`
|
||||||
caret := row + `//*[local-name()="svg"]`
|
caret := row + `//*[local-name()="svg"]`
|
||||||
|
caretUp := caret + `[contains(@class, "rotate-180")]`
|
||||||
|
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
|
||||||
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
||||||
deliveryRow := `//span[text()="` + targetName + `"]`
|
deliveryRow := `//span[text()="` + targetName + `"]`
|
||||||
attempt := `//span[text()="Attempt 1"]`
|
attempt := `//span[text()="Attempt 1"]`
|
||||||
@@ -524,6 +640,10 @@ func checkEventLog(
|
|||||||
click(ctx, t, id)
|
click(ctx, t, id)
|
||||||
assert.True(t, shown(ctx, expanded),
|
assert.True(t, shown(ctx, expanded),
|
||||||
"clicking the event's ID does not expand it")
|
"clicking the event's ID does not expand it")
|
||||||
|
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
|
||||||
|
"the expanded event's row does not say it is expanded")
|
||||||
|
assert.True(t, shown(ctx, caretUp),
|
||||||
|
"the expanded event's caret does not turn up")
|
||||||
|
|
||||||
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
||||||
|
|
||||||
@@ -538,12 +658,41 @@ func checkEventLog(
|
|||||||
click(ctx, t, caret)
|
click(ctx, t, caret)
|
||||||
assert.True(t, hidden(ctx, expanded),
|
assert.True(t, hidden(ctx, expanded),
|
||||||
"clicking the caret does not collapse the event")
|
"clicking the caret does not collapse the event")
|
||||||
|
assert.True(t, shown(ctx, row+`[@aria-expanded="false"]`),
|
||||||
|
"the collapsed event's row does not say it is collapsed")
|
||||||
|
assert.True(t, shown(ctx, caretDown),
|
||||||
|
"the collapsed event's caret stays turned up")
|
||||||
|
|
||||||
|
click(ctx, t, caret)
|
||||||
|
assert.True(t, shown(ctx, expanded),
|
||||||
|
"clicking the caret again does not expand the event")
|
||||||
|
|
||||||
|
click(ctx, t, id)
|
||||||
|
assert.True(t, hidden(ctx, expanded),
|
||||||
|
"clicking the event's ID again does not collapse it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkEventSelection loads the event log and checks that selecting an
|
||||||
|
// event's ID with the mouse leaves the event as it was: dragging over the
|
||||||
|
// ID leaves it collapsed, the caret's next click still expands it, and a
|
||||||
|
// triple-click on the ID then leaves it expanded.
|
||||||
|
func checkEventSelection(
|
||||||
|
ctx context.Context, t *testing.T, url, eventID string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
id := `//span[text()="` + eventID + `"]`
|
||||||
|
row := id + `/ancestor::div[@role="button"]`
|
||||||
|
caret := row + `//*[local-name()="svg"]`
|
||||||
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
||||||
|
|
||||||
var (
|
var (
|
||||||
selected, state string
|
selected, state string
|
||||||
hasState bool
|
hasState bool
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||||
|
|
||||||
selectText(ctx, t, id)
|
selectText(ctx, t, id)
|
||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -556,19 +705,50 @@ func checkEventLog(
|
|||||||
require.True(t, hasState, "the event's row does not say if it is expanded")
|
require.True(t, hasState, "the event's row does not say if it is expanded")
|
||||||
assert.Equal(t, "false", state, "selecting the event's ID expands it")
|
assert.Equal(t, "false", state, "selecting the event's ID expands it")
|
||||||
|
|
||||||
|
// The caret's click also clears the selection, so the triple-click's
|
||||||
|
// first click finds nothing selected, as a person's would.
|
||||||
click(ctx, t, caret)
|
click(ctx, t, caret)
|
||||||
assert.True(t, shown(ctx, expanded),
|
assert.True(t, shown(ctx, expanded),
|
||||||
"clicking the caret after selecting the ID does not expand the event")
|
"clicking the caret after selecting the ID does not expand the event")
|
||||||
|
|
||||||
click(ctx, t, id)
|
tripleClick(ctx, t, id)
|
||||||
assert.True(t, hidden(ctx, expanded),
|
require.NoError(t, chromedp.Run(
|
||||||
"clicking the event's ID again does not collapse it")
|
ctx,
|
||||||
|
chromedp.Evaluate(`window.getSelection().toString()`, &selected),
|
||||||
|
chromedp.AttributeValue(
|
||||||
|
row, "aria-expanded", &state, &hasState, chromedp.BySearch,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
assert.Contains(t, selected, eventID,
|
||||||
|
"a triple-click does not select the event's ID")
|
||||||
|
assert.Equal(t, "true", state,
|
||||||
|
"a triple-click selecting the event's ID collapses it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkEventKeyboard loads the event log and checks that Tab from the
|
||||||
|
// page's Back link reaches the event's row, the first after it, and that
|
||||||
|
// Enter then expands the event and Space collapses it.
|
||||||
|
func checkEventKeyboard(
|
||||||
|
ctx context.Context, t *testing.T, url, eventID string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
back := `//a[contains(text(), "Back to")]`
|
||||||
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
||||||
|
|
||||||
|
var focused string
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(
|
require.NoError(t, chromedp.Run(
|
||||||
ctx,
|
ctx,
|
||||||
chromedp.Focus(row, chromedp.BySearch),
|
loadPage(url),
|
||||||
chromedp.KeyEvent(kb.Enter),
|
chromedp.Focus(back, chromedp.BySearch),
|
||||||
|
chromedp.KeyEvent(kb.Tab),
|
||||||
|
chromedp.Evaluate(`document.activeElement.textContent`, &focused),
|
||||||
))
|
))
|
||||||
|
require.Contains(t, focused, eventID,
|
||||||
|
"Tab from the Back link does not reach the event's row")
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
|
||||||
assert.True(t, shown(ctx, expanded), "Enter does not expand the event")
|
assert.True(t, shown(ctx, expanded), "Enter does not expand the event")
|
||||||
|
|
||||||
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
|
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
|
||||||
@@ -602,6 +782,31 @@ func selectText(ctx context.Context, t *testing.T, xpath string) {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tripleClick clicks three times in a row in the middle of the element
|
||||||
|
// matching an XPath expression, as a person does to select a whole line
|
||||||
|
// of text. The browser selects a word on the second click and the whole
|
||||||
|
// paragraph on the third.
|
||||||
|
func tripleClick(ctx context.Context, t *testing.T, xpath string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var box *dom.BoxModel
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx, chromedp.Dimensions(xpath, &box, chromedp.BySearch),
|
||||||
|
))
|
||||||
|
|
||||||
|
// The content box's corners, clockwise from its top left.
|
||||||
|
x := (box.Content[0] + box.Content[2]) / 2
|
||||||
|
y := (box.Content[1] + box.Content[5]) / 2
|
||||||
|
|
||||||
|
require.NoError(t, chromedp.Run(
|
||||||
|
ctx,
|
||||||
|
chromedp.MouseClickXY(x, y, chromedp.ClickCount(1)),
|
||||||
|
chromedp.MouseClickXY(x, y, chromedp.ClickCount(2)),
|
||||||
|
chromedp.MouseClickXY(x, y, chromedp.ClickCount(3)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// checkMobileMenu loads a page in a phone-sized window and checks that
|
// checkMobileMenu loads a page in a phone-sized window and checks that
|
||||||
// the menu button opens and closes the mobile menu.
|
// the menu button opens and closes the mobile menu.
|
||||||
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
|
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
|
||||||
|
|||||||
+73
-24
@@ -76,17 +76,31 @@ document.addEventListener("alpine:init", function () {
|
|||||||
window.Alpine.data("collapsible", function () {
|
window.Alpine.data("collapsible", function () {
|
||||||
return {
|
return {
|
||||||
open: false,
|
open: false,
|
||||||
|
// Whether it was open before its latest single click, which
|
||||||
|
// may be the first of a double- or triple-click.
|
||||||
|
wasOpen: false,
|
||||||
init() {
|
init() {
|
||||||
this.open = this.$root.hasAttribute("data-open");
|
this.open = this.$root.hasAttribute("data-open");
|
||||||
},
|
},
|
||||||
toggle() {
|
toggle() {
|
||||||
this.open = !this.open;
|
this.open = !this.open;
|
||||||
},
|
},
|
||||||
// Toggles, except on a click that ends a text selection, such
|
// Toggles on a click, except one that selects text, such as
|
||||||
// as selecting an event's ID to copy it.
|
// selecting an event's ID to copy it. A double- or
|
||||||
toggleUnlessSelecting() {
|
// triple-click selects its text on its second click, after
|
||||||
if (window.getSelection().toString() === "") {
|
// its first may have toggled, so a later click that finds
|
||||||
|
// text selected puts back the state from before the first.
|
||||||
|
toggleUnlessSelecting(event) {
|
||||||
|
const selecting = window.getSelection().toString() !== "";
|
||||||
|
|
||||||
|
if (event.detail === 1) {
|
||||||
|
this.wasOpen = this.open;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selecting) {
|
||||||
this.toggle();
|
this.toggle();
|
||||||
|
} else if (event.detail > 1) {
|
||||||
|
this.open = this.wasOpen;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
get closed() {
|
get closed() {
|
||||||
@@ -99,24 +113,65 @@ document.addEventListener("alpine:init", function () {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// The add target form. Only the chosen type's fields show, and the
|
// The targets section's add target form, in three steps: closed,
|
||||||
// others are disabled so that the form does not submit them.
|
// choosing a type, then filling in that type's fields. targetType
|
||||||
|
// is empty until Next takes it from the type select.
|
||||||
//
|
//
|
||||||
// The type is read from the type select when Alpine starts, when the
|
// The reason and the fields' values come from the properties below
|
||||||
// select changes, and on pageshow. Going back to the page, the
|
// rather than from the markup, because each type's fields are made
|
||||||
// browser restores the type chosen before without a change event,
|
// afresh from the markup whenever that type is chosen. A refused
|
||||||
// in some browsers only after Alpine has started, but always before
|
// submission comes back with its type, reason and values in the
|
||||||
// pageshow.
|
// section's data attributes, and starts on that type's fields with
|
||||||
|
// them. Cancel empties these properties and resets the form, which
|
||||||
|
// holds whatever was typed, so the next Add starts with an empty
|
||||||
|
// form and no reason.
|
||||||
window.Alpine.data("targetForm", function () {
|
window.Alpine.data("targetForm", function () {
|
||||||
return {
|
return {
|
||||||
|
choosing: false,
|
||||||
targetType: "",
|
targetType: "",
|
||||||
|
reason: "",
|
||||||
|
name: "",
|
||||||
|
url: "",
|
||||||
|
headers: "",
|
||||||
|
timeout: "",
|
||||||
|
maxRetries: "",
|
||||||
|
expiry: "",
|
||||||
init() {
|
init() {
|
||||||
this.readType();
|
const refused = this.$root.dataset;
|
||||||
|
|
||||||
|
this.targetType = refused.type;
|
||||||
|
this.reason = refused.reason;
|
||||||
|
this.name = refused.name;
|
||||||
|
this.url = refused.destination;
|
||||||
|
this.headers = refused.headers;
|
||||||
|
this.timeout = refused.timeout;
|
||||||
|
this.maxRetries = refused.maxRetries;
|
||||||
|
this.expiry = refused.expiry;
|
||||||
},
|
},
|
||||||
readType() {
|
add() {
|
||||||
this.targetType = this.$root.querySelector(
|
this.choosing = true;
|
||||||
'select[name="type"]'
|
},
|
||||||
).value;
|
next() {
|
||||||
|
this.targetType = this.$refs.type.value;
|
||||||
|
this.choosing = false;
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
this.choosing = false;
|
||||||
|
this.targetType = "";
|
||||||
|
this.reason = "";
|
||||||
|
this.name = "";
|
||||||
|
this.url = "";
|
||||||
|
this.headers = "";
|
||||||
|
this.timeout = "";
|
||||||
|
this.maxRetries = "";
|
||||||
|
this.expiry = "";
|
||||||
|
this.$refs.form.reset();
|
||||||
|
},
|
||||||
|
get filling() {
|
||||||
|
return this.targetType !== "";
|
||||||
|
},
|
||||||
|
get closed() {
|
||||||
|
return !this.choosing && !this.filling;
|
||||||
},
|
},
|
||||||
get isHttp() {
|
get isHttp() {
|
||||||
return this.targetType === "http";
|
return this.targetType === "http";
|
||||||
@@ -127,14 +182,8 @@ document.addEventListener("alpine:init", function () {
|
|||||||
get isDatabase() {
|
get isDatabase() {
|
||||||
return this.targetType === "database";
|
return this.targetType === "database";
|
||||||
},
|
},
|
||||||
get notHttp() {
|
get isLog() {
|
||||||
return !this.isHttp;
|
return this.targetType === "log";
|
||||||
},
|
|
||||||
get notSlack() {
|
|
||||||
return !this.isSlack;
|
|
||||||
},
|
|
||||||
get notDatabase() {
|
|
||||||
return !this.isDatabase;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+104
-43
@@ -97,6 +97,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- The URL above is the entrypoint's credential:
|
<!-- The URL above is the entrypoint's credential:
|
||||||
anyone holding it can submit events. -->
|
anyone holding it can submit events. -->
|
||||||
|
<div class="text-xs text-gray-500 mt-1">
|
||||||
|
<span class="font-medium text-gray-700">Last Event:</span>
|
||||||
|
{{if .LastEvent}}
|
||||||
|
<span title="{{.LastEventUTC}}">{{.LastEvent}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span>never</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">
|
||||||
|
<span class="font-medium text-gray-700">Events Within Retention:</span>
|
||||||
|
<span>{{.Events}}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
|
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
|
||||||
@@ -104,11 +116,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Targets -->
|
<!-- Targets. The data attributes carry a refused add target
|
||||||
<div class="card" x-data="collapsible">
|
submission's type, reason and values back to the form. The
|
||||||
|
URL is data-destination, not data-url: html/template treats
|
||||||
|
an attribute named like a URL as a link and would rewrite
|
||||||
|
a refused ftp: or javascript: value. -->
|
||||||
|
<div class="card" x-data="targetForm"
|
||||||
|
data-type="{{.TargetForm.Type}}"
|
||||||
|
data-reason="{{.TargetError}}"
|
||||||
|
data-name="{{.TargetForm.Name}}"
|
||||||
|
data-destination="{{.TargetForm.URL}}"
|
||||||
|
data-headers="{{.TargetForm.Headers}}"
|
||||||
|
data-timeout="{{.TargetForm.Timeout}}"
|
||||||
|
data-max-retries="{{.TargetForm.MaxRetries}}"
|
||||||
|
data-expiry="{{.TargetForm.Expiry}}">
|
||||||
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
||||||
<button type="button" @click="toggle" class="btn-small">
|
<button type="button" @click="add" x-show="closed" class="btn-small">
|
||||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -116,48 +140,85 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add target form -->
|
<!-- Add target form. Add shows the type choice; Next replaces
|
||||||
<div x-show="open" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
it with the chosen type's fields. Each type's fields,
|
||||||
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-data="targetForm" @pageshow.window="readType" class="space-y-3">
|
and the hidden type field submitted with them, exist
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
only while that type is chosen. A refused submission
|
||||||
<div class="flex gap-2">
|
comes back open on its type, with the values entered;
|
||||||
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
Cancel empties the form. The type choice's p-2, narrower
|
||||||
<select name="type" @change="readType" class="input text-sm w-32">
|
than an input's own padding, keeps it, Next and Cancel on
|
||||||
<option value="http">HTTP</option>
|
one row on a 360px-wide phone. -->
|
||||||
<option value="slack">Slack</option>
|
<form method="POST" action="/hook/{{.Webhook.ID}}/targets" x-ref="form">
|
||||||
<option value="database">Database</option>
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<option value="log">Log</option>
|
<div x-show="choosing" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 flex flex-wrap gap-2">
|
||||||
</select>
|
<select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1">
|
||||||
</div>
|
<option value="http">HTTP</option>
|
||||||
<div x-show="isHttp">
|
<option value="slack">Slack</option>
|
||||||
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="notHttp" class="input text-sm">
|
<option value="database">Database</option>
|
||||||
</div>
|
<option value="log">Log</option>
|
||||||
<div x-show="isHttp">
|
</select>
|
||||||
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="notHttp" class="input text-sm"></textarea>
|
<button type="button" @click="next" class="btn-primary text-sm">Next</button>
|
||||||
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
<button type="button" @click="cancel" class="btn-secondary text-sm">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isHttp" class="flex gap-2 items-center">
|
<div x-show="filling" x-cloak class="p-4 bg-gray-50 border-b border-gray-200 space-y-3">
|
||||||
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
<div x-show="reason" x-text="reason" class="alert-error"></div>
|
||||||
<input type="number" name="timeout" min="0" max="300" :disabled="notHttp" class="input text-sm w-24">
|
<input type="text" name="name" :value="name" placeholder="Target name" required class="input text-sm">
|
||||||
</div>
|
<template x-if="isHttp">
|
||||||
<div x-show="isHttp">
|
<div class="space-y-3">
|
||||||
<div class="flex gap-2 items-center">
|
<input type="hidden" name="type" value="http">
|
||||||
<label class="text-sm text-gray-700">Max retries:</label>
|
<input type="url" name="url" :value="url" placeholder="https://example.com/webhook" class="input text-sm">
|
||||||
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
<div>
|
||||||
|
<textarea name="headers" rows="3" :value="headers" placeholder="Authorization: Bearer ..." class="input text-sm"></textarea>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
||||||
|
<input type="number" name="timeout" :value="timeout" min="0" max="300" class="input text-sm w-24">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<label class="text-sm text-gray-700">Max retries:</label>
|
||||||
|
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
</template>
|
||||||
|
<template x-if="isSlack">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<input type="hidden" name="type" value="slack">
|
||||||
|
<div>
|
||||||
|
<input type="url" name="url" :value="url" placeholder="https://hooks.slack.com/services/..." class="input text-sm">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<label class="text-sm text-gray-700">Max retries:</label>
|
||||||
|
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="isDatabase">
|
||||||
|
<div>
|
||||||
|
<input type="hidden" name="type" value="database">
|
||||||
|
<input type="text" name="expiry" :value="expiry" placeholder="never" class="input text-sm">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="isLog">
|
||||||
|
<div>
|
||||||
|
<input type="hidden" name="type" value="log">
|
||||||
|
<p class="text-xs text-gray-500">A log target writes each event to the application log. It has no settings beyond its name.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary text-sm">Save</button>
|
||||||
|
<button type="button" @click="cancel" class="btn-secondary text-sm">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="isSlack">
|
</div>
|
||||||
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="notSlack" class="input text-sm">
|
</form>
|
||||||
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
|
||||||
</div>
|
|
||||||
<div x-show="isDatabase">
|
|
||||||
<input type="text" name="expiry" placeholder="never" :disabled="notDatabase" class="input text-sm">
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Targets}}
|
{{range .Targets}}
|
||||||
|
|||||||
Reference in New Issue
Block a user