Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7ee1df8df | ||
|
|
30e65dce53 | ||
|
|
1ac4fe0be4 | ||
|
|
a56f1fe0c8 |
@@ -157,6 +157,21 @@ public cloud metadata addresses: currently only `168.63.129.16`, Azure's
|
|||||||
WireServer, which serves an Azure VM its credentials. Because it is a
|
WireServer, which serves an Azure VM its credentials. Because it is a
|
||||||
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
|
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
|
||||||
|
|
||||||
|
That is all the default blocklist covers: the IPv4 private and reserved
|
||||||
|
ranges; of IPv6, only loopback (`::1`), unique local addresses
|
||||||
|
(`fc00::/7`) and link-local addresses (`fe80::/10`); and certain public
|
||||||
|
addresses. A public address belongs on the default blocklist only if it
|
||||||
|
hands credentials, user data or bootstrap material to whatever can reach
|
||||||
|
it, without the caller presenting anything. A provider's other public
|
||||||
|
addresses are not refused. IBM Cloud, for example, serves its package
|
||||||
|
mirrors, time servers and object storage on `161.26.0.0/16`, and the
|
||||||
|
private endpoints of its own cloud services on `166.8.0.0/14`. Neither
|
||||||
|
range hands out credentials that way: the token service among those
|
||||||
|
endpoints issues a token only in exchange for something the caller
|
||||||
|
presents, such as an API key. Reaching these services can be a
|
||||||
|
legitimate delivery, and every cloud has some, so a partial list would
|
||||||
|
promise coverage it does not give.
|
||||||
|
|
||||||
That default is also inconvenient for the thing webhooker is mostly
|
That default is also inconvenient for the thing webhooker is mostly
|
||||||
for: taking a public webhook and forwarding it to something on your own
|
for: taking a public webhook and forwarding it to something on your own
|
||||||
network. A container on the same Docker network, a box on `10.x`, a
|
network. A container on the same Docker network, a box on `10.x`, a
|
||||||
@@ -1642,6 +1657,7 @@ data for auditing, for replay, and for resubmission.
|
|||||||
| `headers` | JSON | Complete request headers |
|
| `headers` | JSON | Complete request headers |
|
||||||
| `body` | text | Raw request body |
|
| `body` | text | Raw request body |
|
||||||
| `content_type` | string | Content-Type header value |
|
| `content_type` | string | Content-Type header value |
|
||||||
|
| `body_bytes` | integer | The body's size in bytes, recorded when the event is stored, on receipt and on resubmit |
|
||||||
| `resubmitted_from_id` | UUID | The event this one was copied from by a resubmit (nullable; empty for an event that arrived on the receiver). Not a foreign key: the source event can be reaped by retention while its copies remain |
|
| `resubmitted_from_id` | UUID | The event this one was copied from by a resubmit (nullable; empty for an event that arrived on the receiver). Not a foreign key: the source event can be reaped by retention while its copies remain |
|
||||||
|
|
||||||
**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many
|
**Relations:** Belongs to Webhook. Belongs to Entrypoint. Has many
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ go 1.26.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||||
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/getsentry/sentry-go v0.25.0
|
github.com/getsentry/sentry-go v0.25.0
|
||||||
github.com/go-chi/chi v1.5.5
|
github.com/go-chi/chi v1.5.5
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
@@ -29,7 +30,6 @@ require (
|
|||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -79,3 +80,14 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
|
|||||||
func DummyPasswordHashForTest() string {
|
func DummyPasswordHashForTest() string {
|
||||||
return dummyPasswordHash()
|
return dummyPasswordHash()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HashAtShippedCostForTest makes HashPassword hash at the shipped
|
||||||
|
// memory cost until t ends. t must not run in parallel with other
|
||||||
|
// tests, which would hash at that cost alongside it.
|
||||||
|
func HashAtShippedCostForTest(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hashAtShippedCostInTest = true
|
||||||
|
|
||||||
|
t.Cleanup(func() { hashAtShippedCostInTest = false })
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ type Event struct {
|
|||||||
Body string `gorm:"type:text" json:"body"`
|
Body string `gorm:"type:text" json:"body"`
|
||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
|
|
||||||
|
// BodyBytes is the size of Body in bytes, recorded when the event
|
||||||
|
// is stored so the recent events list can show it without reading
|
||||||
|
// the body.
|
||||||
|
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied from by
|
// ResubmittedFromID names the event this one was copied from by
|
||||||
// an operator resubmit. It is nil for an event that arrived on
|
// an operator resubmit. It is nil for an event that arrived on
|
||||||
// the receiver, which is every event created before the column
|
// the receiver, which is every event created before the column
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
)
|
)
|
||||||
@@ -63,10 +64,30 @@ func DefaultPasswordConfig() *PasswordConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashPassword generates an Argon2id hash of the password
|
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test
|
||||||
|
// binary hashes with: 1 MB instead of the shipped 64 MB. Every test
|
||||||
|
// that starts a database hashes the bootstrap admin password, dozens
|
||||||
|
// of them run in parallel, and under the race detector each 64 MB hash
|
||||||
|
// holds about 150 MB. VerifyPassword reads the cost from the hash it
|
||||||
|
// checks, so verification follows.
|
||||||
|
const testArgon2Memory = 1024
|
||||||
|
|
||||||
|
// hashAtShippedCostInTest makes a test binary hash at the shipped
|
||||||
|
// memory cost. Only TestHashPassword_ShippedParameters sets it.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // set by one test, see above
|
||||||
|
var hashAtShippedCostInTest bool
|
||||||
|
|
||||||
|
// HashPassword generates an Argon2id hash of the password. A binary
|
||||||
|
// built by go test hashes at testArgon2Memory; one built by go build
|
||||||
|
// always hashes at the defaults.
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
config := DefaultPasswordConfig()
|
config := DefaultPasswordConfig()
|
||||||
|
|
||||||
|
if testing.Testing() && !hashAtShippedCostInTest {
|
||||||
|
config.Memory = testArgon2Memory
|
||||||
|
}
|
||||||
|
|
||||||
// Generate a salt
|
// Generate a salt
|
||||||
salt := make([]byte, config.SaltLen)
|
salt := make([]byte, config.SaltLen)
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,39 @@ func TestHashPasswordUniqueness(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHashPassword_ShippedParameters hashes and verifies through
|
||||||
|
// HashPassword at the shipped Argon2id parameters. Every other test
|
||||||
|
// hashes at the lower memory cost a test binary uses, so this is the
|
||||||
|
// one that keeps production hashing covered. One hash and one
|
||||||
|
// verification: each costs 64 MB.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // changes the hashing cost for the whole binary
|
||||||
|
func TestHashPassword_ShippedParameters(t *testing.T) {
|
||||||
|
database.HashAtShippedCostForTest(t)
|
||||||
|
|
||||||
|
password := "correct horse battery staple"
|
||||||
|
|
||||||
|
hash, err := database.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hashing with the shipped parameters: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shipped = "$argon2id$v=19$m=65536,t=1,p=4$"
|
||||||
|
|
||||||
|
if !strings.HasPrefix(hash, shipped) {
|
||||||
|
t.Errorf("hash = %q, want prefix %q", hash, shipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := database.VerifyPassword(password, hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyPassword() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
t.Error("VerifyPassword() returned false for correct password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
||||||
// path. Login charges an unknown username a verification against a
|
// path. Login charges an unknown username a verification against a
|
||||||
// dummy hash so that a nonexistent account is not answered in
|
// dummy hash so that a nonexistent account is not answered in
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ var (
|
|||||||
// permit specific blocks out of this set with
|
// permit specific blocks out of this set with
|
||||||
// ALLOWED_EGRESS_CIDRS; see Guard.
|
// ALLOWED_EGRESS_CIDRS; see Guard.
|
||||||
//
|
//
|
||||||
|
// A public address belongs on the default blocklist only if it
|
||||||
|
// hands credentials, user data or bootstrap material to whatever
|
||||||
|
// can reach it, without the caller presenting anything. A
|
||||||
|
// provider's other public addresses are not refused, since
|
||||||
|
// reaching them can be legitimate and no list of them could be
|
||||||
|
// complete.
|
||||||
|
//
|
||||||
//nolint:gochecknoglobals // package-level network list is appropriate here
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
||||||
var blockedNetworks []*net.IPNet
|
var blockedNetworks []*net.IPNet
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ func assertEventCopy(
|
|||||||
assert.Equal(t, original.Method, fresh.Method)
|
assert.Equal(t, original.Method, fresh.Method)
|
||||||
assert.Equal(t, original.Headers, fresh.Headers)
|
assert.Equal(t, original.Headers, fresh.Headers)
|
||||||
assert.Equal(t, original.Body, fresh.Body)
|
assert.Equal(t, original.Body, fresh.Body)
|
||||||
|
assert.Equal(t, int64(len(original.Body)), fresh.BodyBytes)
|
||||||
assert.Equal(t, original.ContentType, fresh.ContentType)
|
assert.Equal(t, original.ContentType, fresh.ContentType)
|
||||||
assert.Equal(t, original.EntrypointID, fresh.EntrypointID)
|
assert.Equal(t, original.EntrypointID, fresh.EntrypointID)
|
||||||
assert.Equal(t, original.WebhookID, fresh.WebhookID)
|
assert.Equal(t, original.WebhookID, fresh.WebhookID)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
// maxBodyShift is the bit shift for 1 MB body limit.
|
// maxBodyShift is the bit shift for 1 MB body limit.
|
||||||
maxBodyShift = 20
|
maxBodyShift = 20
|
||||||
// recentEventLimit is the number of recent events to show.
|
// recentEventLimit is the number of recent events to show.
|
||||||
recentEventLimit = 20
|
recentEventLimit = 50
|
||||||
// paginationPerPage is the number of items per page.
|
// paginationPerPage is the number of items per page.
|
||||||
paginationPerPage = 25
|
paginationPerPage = 25
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ func TestHandleProfile_OwnProfile_OK(t *testing.T) {
|
|||||||
h.HandleProfile().ServeHTTP(w, req)
|
h.HandleProfile().ServeHTTP(w, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Account Information")
|
||||||
|
assert.NotContains(t, w.Body.String(), "Account Type")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) {
|
func TestHandleProfile_OtherProfile_Forbidden(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recentEventColumns is the recent events list's projection. It
|
||||||
|
// leaves out the body, for the reason maxRenderedBodyBytes gives,
|
||||||
|
// and reads its size from body_bytes, recorded when the event was
|
||||||
|
// stored.
|
||||||
|
const recentEventColumns = "id, created_at, method, content_type, " +
|
||||||
|
"resubmitted_from_id, body_bytes"
|
||||||
|
|
||||||
|
// recentAttemptColumns is the part of a recorded attempt the list
|
||||||
|
// uses. The event log's deliveryResultColumns also reads response
|
||||||
|
// bodies, which the list does not show.
|
||||||
|
const recentAttemptColumns = "delivery_id, status_code, created_at"
|
||||||
|
|
||||||
|
// RecentEventView is one row of the recent events list on a
|
||||||
|
// webhook's page.
|
||||||
|
type RecentEventView struct {
|
||||||
|
Method string
|
||||||
|
ContentType string
|
||||||
|
|
||||||
|
// ResubmittedFromID names the event this one was copied from,
|
||||||
|
// empty for an event that arrived on the receiver.
|
||||||
|
ResubmittedFromID string
|
||||||
|
|
||||||
|
// Received is how long ago the event arrived, and ReceivedUTC
|
||||||
|
// the full timestamp the page shows on hover.
|
||||||
|
Received string
|
||||||
|
ReceivedUTC string
|
||||||
|
|
||||||
|
// Size is the size of the stored body.
|
||||||
|
Size string
|
||||||
|
|
||||||
|
// ProcessingTime is how long the event's slowest delivery
|
||||||
|
// took; see processingTime.
|
||||||
|
ProcessingTime string
|
||||||
|
|
||||||
|
// Status is what the webhook's HTTP target answered, and
|
||||||
|
// StatusClass its colour; see targetStatus. Both are empty
|
||||||
|
// unless the webhook has exactly one HTTP target.
|
||||||
|
Status string
|
||||||
|
StatusClass string
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentEventRow is one row of recentEventColumns.
|
||||||
|
type recentEventRow struct {
|
||||||
|
ID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Method string
|
||||||
|
ContentType string
|
||||||
|
ResubmittedFromID *string
|
||||||
|
BodyBytes uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
|
||||||
|
// when the attempt's result was recorded, which is when the attempt
|
||||||
|
// finished.
|
||||||
|
type recentAttemptRow struct {
|
||||||
|
DeliveryID string
|
||||||
|
StatusCode int
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// singleHTTPTargetID returns the ID of the webhook's HTTP target
|
||||||
|
// when it has exactly one, and "" when it has none or several.
|
||||||
|
func singleHTTPTargetID(targets []database.Target) string {
|
||||||
|
id := ""
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
for i := range targets {
|
||||||
|
if targets[i].Type == database.TargetTypeHTTP {
|
||||||
|
id = targets[i].ID
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRecentEvents loads the webhook's recentEventLimit newest
|
||||||
|
// events for its page, newest first. statusTargetID is the
|
||||||
|
// webhook's only HTTP target, or "" when the list shows no status.
|
||||||
|
func loadRecentEvents(
|
||||||
|
webhookDB *gorm.DB, webhookID, statusTargetID string,
|
||||||
|
) ([]RecentEventView, error) {
|
||||||
|
var rows []recentEventRow
|
||||||
|
|
||||||
|
err := webhookDB.Model(&database.Event{}).
|
||||||
|
Select(recentEventColumns).
|
||||||
|
Where("webhook_id = ?", webhookID).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Limit(recentEventLimit).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
eventIDs := make([]string, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
eventIDs[i] = rows[i].ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest first, so an event's last delivery to a target is its
|
||||||
|
// newest: a replay adds a delivery rather than changing the
|
||||||
|
// earlier one.
|
||||||
|
var deliveries []database.Delivery
|
||||||
|
|
||||||
|
err = webhookDB.
|
||||||
|
Select("id, event_id, target_id, status, created_at").
|
||||||
|
Where("event_id IN ?", eventIDs).
|
||||||
|
Order("created_at ASC").
|
||||||
|
Find(&deliveries).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
byEvent := make(map[string][]database.Delivery, len(rows))
|
||||||
|
deliveryIDs := make([]string, len(deliveries))
|
||||||
|
|
||||||
|
for i := range deliveries {
|
||||||
|
eventID := deliveries[i].EventID
|
||||||
|
byEvent[eventID] = append(byEvent[eventID], deliveries[i])
|
||||||
|
deliveryIDs[i] = deliveries[i].ID
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts, err := loadRecentAttempts(webhookDB, deliveryIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]RecentEventView, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
views[i] = rows[i].view(
|
||||||
|
byEvent[rows[i].ID], attempts, statusTargetID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRecentAttempts loads the recorded attempts of the listed
|
||||||
|
// events' deliveries, keyed by delivery ID, each delivery's in
|
||||||
|
// attempt order. The IDs go in chunks for the reason
|
||||||
|
// deliveryIDChunkSize gives.
|
||||||
|
func loadRecentAttempts(
|
||||||
|
webhookDB *gorm.DB, deliveryIDs []string,
|
||||||
|
) (map[string][]recentAttemptRow, error) {
|
||||||
|
byDelivery := make(map[string][]recentAttemptRow)
|
||||||
|
|
||||||
|
for chunk := range slices.Chunk(deliveryIDs, deliveryIDChunkSize) {
|
||||||
|
var rows []recentAttemptRow
|
||||||
|
|
||||||
|
err := webhookDB.Model(&database.DeliveryResult{}).
|
||||||
|
Select(recentAttemptColumns).
|
||||||
|
Where("delivery_id IN ?", chunk).
|
||||||
|
Order("attempt_num ASC").
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range rows {
|
||||||
|
id := rows[i].DeliveryID
|
||||||
|
byDelivery[id] = append(byDelivery[id], rows[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return byDelivery, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// view projects a loaded row for rendering. deliveries is the
|
||||||
|
// event's deliveries, oldest first, and attempts their recorded
|
||||||
|
// attempts keyed by delivery ID.
|
||||||
|
func (r *recentEventRow) view(
|
||||||
|
deliveries []database.Delivery,
|
||||||
|
attempts map[string][]recentAttemptRow,
|
||||||
|
statusTargetID string,
|
||||||
|
) RecentEventView {
|
||||||
|
v := RecentEventView{
|
||||||
|
Method: r.Method,
|
||||||
|
ContentType: r.ContentType,
|
||||||
|
Received: humanize.Time(r.CreatedAt),
|
||||||
|
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
||||||
|
Size: humanize.Bytes(r.BodyBytes),
|
||||||
|
ProcessingTime: processingTime(deliveries, attempts),
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.ResubmittedFromID != nil {
|
||||||
|
v.ResubmittedFromID = *r.ResubmittedFromID
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusTargetID != "" {
|
||||||
|
v.Status, v.StatusClass = targetStatus(
|
||||||
|
deliveries, attempts, statusTargetID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// processingTime is how long the event's slowest delivery took,
|
||||||
|
// from being queued to its last recorded attempt, time spent
|
||||||
|
// waiting between retries included. A delivery is queued when its
|
||||||
|
// event is received, or when an operator replays it, so a replay
|
||||||
|
// is timed from the replay rather than from the event's arrival.
|
||||||
|
// It is "in progress" while any delivery is pending or retrying,
|
||||||
|
// and empty for an event with no deliveries.
|
||||||
|
func processingTime(
|
||||||
|
deliveries []database.Delivery,
|
||||||
|
attempts map[string][]recentAttemptRow,
|
||||||
|
) string {
|
||||||
|
if len(deliveries) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var slowest time.Duration
|
||||||
|
|
||||||
|
for i := range deliveries {
|
||||||
|
if !deliveries[i].Status.Terminal() {
|
||||||
|
return "in progress"
|
||||||
|
}
|
||||||
|
|
||||||
|
tries := attempts[deliveries[i].ID]
|
||||||
|
if len(tries) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
last := tries[len(tries)-1].CreatedAt
|
||||||
|
slowest = max(slowest, last.Sub(deliveries[i].CreatedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
return slowest.Round(time.Millisecond).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetStatus is what the target answered for the event, and the
|
||||||
|
// colour to show it in: the HTTP status code of the last attempt of
|
||||||
|
// the event's newest delivery to the target. Without a code it is
|
||||||
|
// "no response" when that attempt failed before a response
|
||||||
|
// arrived, the delivery's status ("pending") before any attempt,
|
||||||
|
// and "not sent" when the event has no delivery to the target.
|
||||||
|
func targetStatus(
|
||||||
|
deliveries []database.Delivery,
|
||||||
|
attempts map[string][]recentAttemptRow,
|
||||||
|
targetID string,
|
||||||
|
) (string, string) {
|
||||||
|
newest := -1
|
||||||
|
|
||||||
|
for i := range deliveries {
|
||||||
|
if deliveries[i].TargetID == targetID {
|
||||||
|
newest = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if newest < 0 {
|
||||||
|
return "not sent", "text-gray-400"
|
||||||
|
}
|
||||||
|
|
||||||
|
tries := attempts[deliveries[newest].ID]
|
||||||
|
if len(tries) == 0 {
|
||||||
|
return string(deliveries[newest].Status), "text-gray-400"
|
||||||
|
}
|
||||||
|
|
||||||
|
code := tries[len(tries)-1].StatusCode
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case code == 0:
|
||||||
|
return "no response", "text-red-600"
|
||||||
|
case code >= http.StatusInternalServerError:
|
||||||
|
return strconv.Itoa(code), "text-red-600"
|
||||||
|
case code >= http.StatusBadRequest:
|
||||||
|
return strconv.Itoa(code), "text-yellow-600"
|
||||||
|
case code >= http.StatusMultipleChoices:
|
||||||
|
return strconv.Itoa(code), "text-gray-500"
|
||||||
|
case code >= http.StatusOK:
|
||||||
|
return strconv.Itoa(code), "text-green-600"
|
||||||
|
default:
|
||||||
|
return strconv.Itoa(code), "text-gray-500"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statusTitle marks the status column's cell in a recent events
|
||||||
|
// row; it is absent from the page when the column is not shown.
|
||||||
|
const statusTitle = `title="HTTP status from the HTTP target"`
|
||||||
|
|
||||||
|
// recentEventsFixture is one started app and a webhook whose
|
||||||
|
// recent events list a test fills.
|
||||||
|
type recentEventsFixture struct {
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
webhook *database.Webhook
|
||||||
|
webhookDB *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRecentEventsFixture(t *testing.T) *recentEventsFixture {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
f := &recentEventsFixture{}
|
||||||
|
|
||||||
|
var dbMgr *database.WebhookDBManager
|
||||||
|
|
||||||
|
app := newTestApp(t, &f.h, &f.sess, &f.db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
f.webhook = seedWebhook(t, f.db)
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(f.webhook.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
f.webhookDB = webhookDB
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *recentEventsFixture) render(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return renderSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// event records an event received at receivedAt, with its body's
|
||||||
|
// size as the receiver records it.
|
||||||
|
func (f *recentEventsFixture) event(
|
||||||
|
t *testing.T, contentType, body string, receivedAt time.Time,
|
||||||
|
) *database.Event {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
event := &database.Event{
|
||||||
|
WebhookID: f.webhook.ID,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Body: body,
|
||||||
|
BodyBytes: int64(len(body)),
|
||||||
|
ContentType: contentType,
|
||||||
|
}
|
||||||
|
event.CreatedAt = receivedAt
|
||||||
|
|
||||||
|
require.NoError(t, f.webhookDB.Omit(
|
||||||
|
clause.Associations,
|
||||||
|
).Create(event).Error)
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
// delivery records a delivery of the event to the target, queued
|
||||||
|
// when the event was received.
|
||||||
|
func (f *recentEventsFixture) delivery(
|
||||||
|
t *testing.T,
|
||||||
|
event *database.Event,
|
||||||
|
targetID string,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
) *database.Delivery {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return f.deliveryQueuedAt(
|
||||||
|
t, event, targetID, status, event.CreatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliveryQueuedAt records a delivery of the event to the target,
|
||||||
|
// queued at queuedAt, as a replay is.
|
||||||
|
func (f *recentEventsFixture) deliveryQueuedAt(
|
||||||
|
t *testing.T,
|
||||||
|
event *database.Event,
|
||||||
|
targetID string,
|
||||||
|
status database.DeliveryStatus,
|
||||||
|
queuedAt time.Time,
|
||||||
|
) *database.Delivery {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dlv := &database.Delivery{
|
||||||
|
EventID: event.ID,
|
||||||
|
TargetID: targetID,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
dlv.CreatedAt = queuedAt
|
||||||
|
|
||||||
|
require.NoError(t, f.webhookDB.Omit(
|
||||||
|
clause.Associations,
|
||||||
|
).Create(dlv).Error)
|
||||||
|
|
||||||
|
return dlv
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt records one attempt of the delivery that finished took
|
||||||
|
// after the delivery was queued, with HTTP status code (0 for no
|
||||||
|
// response).
|
||||||
|
func (f *recentEventsFixture) attempt(
|
||||||
|
t *testing.T, dlv *database.Delivery, code int, took time.Duration,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
result := &database.DeliveryResult{
|
||||||
|
DeliveryID: dlv.ID,
|
||||||
|
AttemptNum: 1,
|
||||||
|
StatusCode: code,
|
||||||
|
}
|
||||||
|
result.CreatedAt = dlv.CreatedAt.Add(took)
|
||||||
|
|
||||||
|
require.NoError(t, f.webhookDB.Omit(
|
||||||
|
clause.Associations,
|
||||||
|
).Create(result).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusCell is the status column's cell as the page renders it.
|
||||||
|
func statusCell(class, text string) string {
|
||||||
|
return `<span class="font-medium ` + class + `" ` + statusTitle +
|
||||||
|
`>` + text + `</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_ShowsFiftyNewestEvents proves the list is
|
||||||
|
// headed "50 Most Recent Events" and holds the 50 newest events,
|
||||||
|
// newest first, and not one more.
|
||||||
|
func TestHandleSourceDetail_ShowsFiftyNewestEvents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
base := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
for i := range 51 {
|
||||||
|
f.event(
|
||||||
|
t, fmt.Sprintf("application/x-recent-%02d", i), "{}",
|
||||||
|
base.Add(time.Duration(i)*time.Second),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := f.render(t)
|
||||||
|
|
||||||
|
assert.Contains(t, body, ">50 Most Recent Events</h2>")
|
||||||
|
assert.Equal(t, 50, strings.Count(body, `title="Body size"`))
|
||||||
|
assert.NotContains(t, body, "application/x-recent-00")
|
||||||
|
assert.Contains(t, body, "application/x-recent-01")
|
||||||
|
assert.Less(
|
||||||
|
t,
|
||||||
|
strings.Index(body, "application/x-recent-50"),
|
||||||
|
strings.Index(body, "application/x-recent-49"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_RecentEventColumns proves a row shows its
|
||||||
|
// time relative with the UTC timestamp on hover, its body size,
|
||||||
|
// and its processing time once every delivery has finished.
|
||||||
|
func TestHandleSourceDetail_RecentEventColumns(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
logTarget := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
receivedAt := time.Now().Add(-210 * time.Second).
|
||||||
|
UTC().Truncate(time.Second)
|
||||||
|
|
||||||
|
done := f.event(
|
||||||
|
t, contentTypeJSON, strings.Repeat("x", 2048), receivedAt,
|
||||||
|
)
|
||||||
|
f.attempt(
|
||||||
|
t,
|
||||||
|
f.delivery(t, done, logTarget.ID, database.DeliveryStatusDelivered),
|
||||||
|
0, 1500*time.Millisecond,
|
||||||
|
)
|
||||||
|
|
||||||
|
waiting := f.event(t, "text/plain", "{}", receivedAt)
|
||||||
|
f.delivery(t, waiting, logTarget.ID, database.DeliveryStatusPending)
|
||||||
|
|
||||||
|
body := f.render(t)
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, body,
|
||||||
|
`<span title="`+receivedAt.Format(time.DateTime)+
|
||||||
|
` UTC">3 minutes ago</span>`,
|
||||||
|
)
|
||||||
|
assert.Contains(t, body, `<span title="Body size">2.0 kB</span>`)
|
||||||
|
assert.Contains(t, body, ">1.5s</span>")
|
||||||
|
assert.Contains(t, body, ">in progress</span>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_StatusWithSingleHTTPTarget proves that a
|
||||||
|
// webhook with exactly one HTTP target shows, colour-coded, what
|
||||||
|
// that target answered for each event. The log target beside it
|
||||||
|
// does not count against "exactly one".
|
||||||
|
func TestHandleSourceDetail_StatusWithSingleHTTPTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
||||||
|
seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for _, code := range []int{204, 302, 404, 503, 0} {
|
||||||
|
dlv := f.delivery(
|
||||||
|
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
f.attempt(t, dlv, code, time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.delivery(
|
||||||
|
t, f.event(t, contentTypeJSON, "{}", now), target.ID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
f.event(t, contentTypeJSON, "{}", now)
|
||||||
|
|
||||||
|
// A replay is a newer delivery, and its answer is the one shown.
|
||||||
|
replayed := f.event(t, contentTypeJSON, "{}", now)
|
||||||
|
f.attempt(t, f.delivery(
|
||||||
|
t, replayed, target.ID, database.DeliveryStatusFailed,
|
||||||
|
), 502, time.Second)
|
||||||
|
f.attempt(t, f.deliveryQueuedAt(
|
||||||
|
t, replayed, target.ID, database.DeliveryStatusDelivered,
|
||||||
|
now.Add(time.Minute),
|
||||||
|
), 200, time.Second)
|
||||||
|
|
||||||
|
body := f.render(t)
|
||||||
|
|
||||||
|
assert.Contains(t, body, statusCell("text-green-600", "204"))
|
||||||
|
assert.Contains(t, body, statusCell("text-gray-500", "302"))
|
||||||
|
assert.Contains(t, body, statusCell("text-yellow-600", "404"))
|
||||||
|
assert.Contains(t, body, statusCell("text-red-600", "503"))
|
||||||
|
assert.Contains(t, body, statusCell("text-red-600", "no response"))
|
||||||
|
assert.Contains(t, body, statusCell("text-gray-400", "pending"))
|
||||||
|
assert.Contains(t, body, statusCell("text-gray-400", "not sent"))
|
||||||
|
assert.Contains(t, body, statusCell("text-green-600", "200"))
|
||||||
|
assert.NotContains(t, body, ">502<")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget proves the
|
||||||
|
// status column is absent when the webhook has no HTTP target or
|
||||||
|
// more than one.
|
||||||
|
func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := map[string][]database.TargetType{
|
||||||
|
"none": {database.TargetTypeLog},
|
||||||
|
"several": {database.TargetTypeHTTP, database.TargetTypeHTTP},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, types := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
event := f.event(t, contentTypeJSON, "{}", time.Now())
|
||||||
|
|
||||||
|
for _, tt := range types {
|
||||||
|
target := seedTarget(t, f.db, f.webhook.ID, tt)
|
||||||
|
f.attempt(t, f.delivery(
|
||||||
|
t, event, target.ID,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
), 200, time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := f.render(t)
|
||||||
|
|
||||||
|
assert.Contains(t, body, `title="Body size"`)
|
||||||
|
assert.NotContains(t, body, statusTitle)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleWebhook_RecordsBodySize proves the receiver records the
|
||||||
|
// body's size in bytes, not characters, with the event it stores.
|
||||||
|
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
seedEntrypoint(t, f.db, f.webhook.ID)
|
||||||
|
|
||||||
|
// Two bytes per character.
|
||||||
|
body := strings.Repeat("é", 1024)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodPost, "/webhook/x",
|
||||||
|
strings.NewReader(body),
|
||||||
|
)
|
||||||
|
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add("uuid", "ep-"+f.webhook.ID)
|
||||||
|
|
||||||
|
req = req.WithContext(context.WithValue(
|
||||||
|
req.Context(), chi.RouteCtxKey, rctx,
|
||||||
|
))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
f.h.HandleWebhook().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var stored database.Event
|
||||||
|
|
||||||
|
require.NoError(t, f.webhookDB.First(&stored).Error)
|
||||||
|
assert.Equal(t, int64(2048), stored.BodyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_FailedLoadIsAnError proves that when the
|
||||||
|
// list cannot be loaded the page answers with an error, rather than
|
||||||
|
// an empty list claiming the webhook has no events.
|
||||||
|
func TestHandleSourceDetail_FailedLoadIsAnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
||||||
|
|
||||||
|
f.attempt(t, f.delivery(
|
||||||
|
t, f.event(t, contentTypeJSON, "{}", time.Now()), target.ID,
|
||||||
|
database.DeliveryStatusDelivered,
|
||||||
|
), 200, time.Second)
|
||||||
|
|
||||||
|
// The attempts are the list's last query, so its events and
|
||||||
|
// deliveries have already loaded when it fails.
|
||||||
|
require.NoError(t, f.webhookDB.Exec(
|
||||||
|
"DROP TABLE delivery_results",
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
w := serveSourceDetailPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.NotContains(t, w.Body.String(), "No events received yet.")
|
||||||
|
}
|
||||||
@@ -62,6 +62,23 @@ func renderSourceDetailPage(
|
|||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
w := serveSourceDetailPage(t, h, sess, webhookID)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveSourceDetailPage runs the real source detail handler for a
|
||||||
|
// webhook and returns its response, whatever its status.
|
||||||
|
func serveSourceDetailPage(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sess *session.Session,
|
||||||
|
webhookID string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
req := httptest.NewRequestWithContext(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
@@ -87,9 +104,7 @@ func renderSourceDetailPage(
|
|||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.HandleSourceDetail().ServeHTTP(w, req)
|
h.HandleSourceDetail().ServeHTTP(w, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
return w
|
||||||
|
|
||||||
return w.Body.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleSourceDetail_MasksSlackWebhookURL is the
|
// TestHandleSourceDetail_MasksSlackWebhookURL is the
|
||||||
|
|||||||
@@ -415,16 +415,23 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
"webhook_id = ?", webhook.ID,
|
"webhook_id = ?", webhook.ID,
|
||||||
).Find(&targets)
|
).Find(&targets)
|
||||||
|
|
||||||
var events []database.Event
|
var events []RecentEventView
|
||||||
|
|
||||||
if h.dbMgr.DBExists(webhook.ID) {
|
if h.dbMgr.DBExists(webhook.ID) {
|
||||||
webhookDB, dbErr := h.dbMgr.GetDB(webhook.ID)
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
if dbErr == nil {
|
if err != nil {
|
||||||
webhookDB.Where(
|
h.serverError(w, "failed to get webhook database", err)
|
||||||
"webhook_id = ?", webhook.ID,
|
|
||||||
).Order("created_at DESC").Limit(
|
return
|
||||||
recentEventLimit,
|
}
|
||||||
).Find(&events)
|
|
||||||
|
events, err = loadRecentEvents(
|
||||||
|
webhookDB, webhook.ID, singleHTTPTargetID(targets),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, "failed to load recent events", err)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ func (s eventSource) event() *database.Event {
|
|||||||
Method: s.Method,
|
Method: s.Method,
|
||||||
Headers: s.HeadersJSON,
|
Headers: s.HeadersJSON,
|
||||||
Body: string(s.Body),
|
Body: string(s.Body),
|
||||||
|
BodyBytes: int64(len(s.Body)),
|
||||||
ContentType: s.ContentType,
|
ContentType: s.ContentType,
|
||||||
ResubmittedFromID: s.ResubmittedFromID,
|
ResubmittedFromID: s.ResubmittedFromID,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ func (n *noopEvictor) EvictWebhook(string) {}
|
|||||||
// and the database, exactly as internal/handlers builds them.
|
// and the database, exactly as internal/handlers builds them.
|
||||||
//
|
//
|
||||||
// One application per test function, not per case: every start that
|
// One application per test function, not per case: every start that
|
||||||
// finds no account seeds one at 64 MB of Argon2id, and this package's
|
// finds no account seeds one with an Argon2id hash, and this package's
|
||||||
// budget is not the place to spend that repeatedly.
|
// budget is not the place to spend that repeatedly.
|
||||||
func newServerApp(
|
func newServerApp(
|
||||||
t *testing.T, dir string,
|
t *testing.T, dir string,
|
||||||
|
|||||||
+6
-1
@@ -22,6 +22,11 @@
|
|||||||
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
|
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
|
||||||
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
||||||
# 67s, that is the datum to revisit the org figure with.
|
# 67s, that is the datum to revisit the org figure with.
|
||||||
|
#
|
||||||
|
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
|
||||||
|
# binaries build or run at once, each with at most eight parallel tests. Under
|
||||||
|
# -race every test binary and every link costs a few hundred MB, so the
|
||||||
|
# defaults (one per core) add up to several GB on a many-core host.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -29,7 +34,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
"$ROOT/script/assets"
|
"$ROOT/script/assets"
|
||||||
go test -v -race -timeout 90s ./...
|
go test -v -race -p 4 -parallel 8 -timeout 90s ./...
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -41,10 +41,6 @@
|
|||||||
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
|
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
|
||||||
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
|
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
|
||||||
<dt class="w-32 text-sm font-medium text-gray-500">Account Type</dt>
|
|
||||||
<dd class="text-sm text-gray-900">Standard User</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -181,18 +181,30 @@
|
|||||||
<!-- Recent Events -->
|
<!-- Recent Events -->
|
||||||
<div class="card mt-6">
|
<div class="card mt-6">
|
||||||
<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">Recent Events</h2>
|
<h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
|
||||||
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100">
|
||||||
{{range .Events}}
|
{{range .Events}}
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<span class="badge-info">{{.Method}}</span>
|
<span class="badge-info">{{.Method}}</span>
|
||||||
<span class="text-sm text-gray-500">{{.ContentType}}</span>
|
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
|
||||||
|
{{if .ResubmittedFromID}}
|
||||||
|
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
|
||||||
|
<span title="Body size">{{.Size}}</span>
|
||||||
|
{{if .ProcessingTime}}
|
||||||
|
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
|
||||||
|
{{end}}
|
||||||
|
{{if .Status}}
|
||||||
|
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
|
||||||
|
{{end}}
|
||||||
|
<span title="{{.ReceivedUTC}}">{{.Received}}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
Reference in New Issue
Block a user