Compare commits

5 Commits
Author SHA1 Message Date
sneak 06083b19a2 Make every clickable control look clickable, in two shared styles (closes #375)
check / check (push) Successful in 3m15s
Buttons keep btn-primary, btn-secondary and btn-danger and gain a
pointer cursor; the site name and the navigation links become
btn-secondary buttons. Every control that was plain coloured text
(Copy, both Add, the row actions, Resubmit, Replay, the back, footer
and download links) now uses btn-small, bordered at rest with hover
and focus states. Each card on the webhook list shows an Open label in
btn-small and takes its focus outline. Both styles are in
static/css/style.css, which the layout now loads.

The event log's clickable rows become buttons, so they work by
keyboard; Replay moves beside its delivery's row. Rows on the webhook
page and in the event log wrap at phone width. The browser test now
checks that Copy reads "Copied".

Model: opus-5-5
2026-10-02 16:06:26 +00:00
clawbot bf3df0312b Clear the environment in the cmd/webhooker tests that build a Config (closes #452)
check / check (push) Successful in 3m16s
The two cmd/webhooker tests that build the app graph, TestNewApp_StopTimeout and TestNewApp_SendsFxEventsToTheLogger, built a Config without clearing the environment, so a variable exported in the developer's shell changed their result: a METRICS_USERNAME without METRICS_PASSWORD failed the second. Both now call config.ClearEnvForTest before setting their own variables, as the config tests and the first-boot test already do. No other test outside internal/config builds a Config through config.New. Test change only.

Model: opus-5-5
2026-10-02 18:03:31 +02:00
clawbot 503c57efd9 Assert the body a retry delivers, not only its status (closes #294)
check / check (push) Successful in 3m24s
TestProcessRetryTask_LargeBody_FetchFromDB and TestProcessRetryTask_SuccessfulRetry checked only that the delivery ended delivered, so deleting the event-body fetch on the retry path, the behaviour the first is named for, left both green while a retry could deliver an empty or truncated body. Both now compare the body the target received with the stored event body byte for byte, and both fail when that fetch is deleted. The other retry-path tests are not about the body and are unchanged. Test change only.

Model: opus-5-5
2026-10-02 18:03:20 +02:00
clawbot d084f4f912 Pin the body cap's order in every page route group (closes #93)
check / check (push) Successful in 3m18s
Follow-ups from an August review of the body cap, each checked against the current tree. One route test now requires an oversized POST, with no session and no CSRF token, to be refused with 413 before CSRF runs, in every page route group with a POST route and in /settings/, so moving a group's body cap after CSRF fails it. The three test router helpers build the server through New with a lifecycle that is never started, so no field is set by hand. The middleware test comment names runMaxBodySize, and the MaxBodySize doc comment says methods other than POST, PUT and PATCH pass uncapped on purpose. The README item was already settled.

Model: opus-5-5
2026-10-02 17:53:21 +02:00
clawbot e67fffb05d Check the log charge against every code point (closes #172)
check / check (push) Successful in 3m24s
The access log's 2,560-byte line ceiling holds only if logfield.EncodedBytes charges each code point at least what the log handlers write for it, and the test checked that on a sample. TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit now covers every code point, surrogates aside, for both handlers. Below U+1000, where the handlers' escaping varies, each code point is measured alone, both in a bare value and in a quoted one, so undercharging any of them, DEL included, fails and names it. From U+1000 up it compares batch sums, which the doc comment says can hide one JSON-only overcharge. Reverting the astral charge to 6 fails the test. It adds under 2 seconds under -race.

Model: opus-5-5
2026-10-02 17:53:10 +02:00
23 changed files with 417 additions and 180 deletions
+11 -10
View File
@@ -1326,15 +1326,16 @@ 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: both add forms stay hidden until Add is
clicked; choosing Slack in the add target form leaves the HTTP fields out of clicked; choosing Slack in the add target form leaves the HTTP fields out of
what it submits, also after leaving the page and going back to it, when the what it submits, also after leaving the page and going back to it, when the
browser restores the choice; an event expands and collapses, and so do a browser restores the choice; the Copy button beside an entrypoint URL reads
delivery's attempts inside it; and at phone width the menu button opens and "Copied" once clicked; an event expands and collapses, and so do a delivery's
closes the mobile menu. It also fails if the browser reports a console warning attempts inside it; and at phone width the menu button opens and closes the
or error, an uncaught exception, or anything the policy refused. `make check` mobile menu. It also fails if the browser reports a console warning or error,
and the image build lint it but do not run it, and `make test` leaves it out an uncaught exception, or anything the policy refused. `make check` and the
(its file is built only with the `browser` build tag). Run it with image build lint it but do not run it, and `make test` leaves it out (its file
`make test-browser` after changing `templates/` or `static/js/`: that builds is built only with the `browser` build tag). Run it with `make test-browser`
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser after changing `templates/` or `static/js/`: that builds `Dockerfile.browser`,
image, so the host needs no browser. which runs the test in a digest-pinned headless browser image, so the host
needs no browser.
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
byte as the npm registry publishes it. It is a dependency, not this repo's build byte as the npm registry publishes it. It is a dependency, not this repo's build
@@ -3026,7 +3027,7 @@ webhooker/
│ ├── static.go # //go:embed directive │ ├── static.go # //go:embed directive
│ ├── css/input.css # Tailwind input, source for tailwind.css (make css) │ ├── css/input.css # Tailwind input, source for tailwind.css (make css)
│ ├── css/tailwind.css # Generated stylesheet the pages load │ ├── css/tailwind.css # Generated stylesheet the pages load
│ ├── css/style.css # Older hand-written stylesheet, no longer loaded │ ├── css/style.css # Hand-written, loaded after tailwind.css: btn-small, the pointer cursor for input.css's buttons, the webhook list cards' focus outline
│ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components │ ├── js/app.js # Copy-to-clipboard, and the Alpine.js components
│ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed │ └── js/alpine.min.js # Alpine.js CSP build, extracted from 3p/ by make assets, not committed
├── templates/ # Go HTML templates (base, login, sources, etc.) ├── templates/ # Go HTML templates (base, login, sources, etc.)
+3
View File
@@ -14,6 +14,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir" "sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/resetpw" "sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server" "sneak.berlin/go/webhooker/internal/server"
@@ -36,6 +37,7 @@ const dockerStopGrace = 10 * time.Second
// fx.New applies options before it executes invokes, so the timeout // fx.New applies options before it executes invokes, so the timeout
// is set whether or not the graph itself can be constructed here. // is set whether or not the graph itself can be constructed here.
func TestNewApp_StopTimeout(t *testing.T) { func TestNewApp_StopTimeout(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("DATA_DIR", t.TempDir()) t.Setenv("DATA_DIR", t.TempDir())
got := newApp().StopTimeout() got := newApp().StopTimeout()
@@ -73,6 +75,7 @@ func freePort(t *testing.T) int {
// anything is built, and the run of logger.New, which happens before // anything is built, and the run of logger.New, which happens before
// the configuration sets the level. // the configuration sets the level.
func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) { func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("DATA_DIR", t.TempDir()) t.Setenv("DATA_DIR", t.TempDir())
t.Setenv("PORT", strconv.Itoa(freePort(t))) t.Setenv("PORT", strconv.Itoa(freePort(t)))
t.Setenv("DEBUG", "true") t.Setenv("DEBUG", "true")
+16 -2
View File
@@ -355,9 +355,14 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
s := newISetup(t) s := newISetup(t)
var receivedBody string
ts := httptest.NewServer( ts := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}, },
), ),
@@ -397,6 +402,8 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
context.TODO(), &task, context.TODO(), &task,
) )
assert.Equal(t, event.Body, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID, iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
) )
@@ -443,9 +450,14 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
s := newISetup(t) s := newISetup(t)
var receivedBody string
ts := httptest.NewServer( ts := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}, },
), ),
@@ -482,6 +494,8 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
context.TODO(), &task, context.TODO(), &task,
) )
assert.Equal(t, largeBody, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID, iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
) )
+6 -5
View File
@@ -82,9 +82,9 @@ func TestNavbarUsesWebhookTerminology(t *testing.T) {
}) })
assert.Contains(t, body, "Retention: 14 days") assert.Contains(t, body, "Retention: 14 days")
assert.Contains(t, body, `class="btn-text">Webhooks</a>`) assert.Contains(t, body, `class="btn-secondary">Webhooks</a>`)
assert.Contains( assert.Contains(
t, body, `class="btn-text w-full text-left">Webhooks</a>`, t, body, `class="btn-secondary w-full">Webhooks</a>`,
) )
assert.Contains( assert.Contains(
t, body, t, body,
@@ -163,7 +163,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
) )
assert.Contains( assert.Contains(
t, detailBody, t, detailBody,
`<a href="/hook/wh-1/events" class="btn-text text-sm">Full Event Log</a>`, `<a href="/hook/wh-1/events" class="btn-small">Full Event Log</a>`,
"the link under recent events", "the link under recent events",
) )
@@ -331,8 +331,9 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
assert.Contains(t, body, "https://hooks.example.com/h/abc123") assert.Contains(t, body, "https://hooks.example.com/h/abc123")
assert.Contains( assert.Contains(
t, body, t, body,
`hidden data-copy-target="entrypoint-url-ep-1"`, `<button type="button" hidden data-copy-target="entrypoint-url-ep-1"`,
"the button must start hidden and be revealed by script", "the copy control must be a button, start hidden and be "+
"revealed by script",
) )
// renderTemplate streams to the ResponseWriter, so an abort // renderTemplate streams to the ResponseWriter, so an abort
+117 -58
View File
@@ -5,6 +5,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"strings" "strings"
"sync"
"testing" "testing"
"unicode/utf8" "unicode/utf8"
@@ -18,15 +19,11 @@ import (
// width. // width.
const budget = 64 const budget = 64
// sampleRunes is how many runes wide the values in the charge test // batchRunes is how many consecutive code points the charge test logs
// are. The handlers add a constant per field — a pair of quotes when // in one value from U+1000 up. Logging each of those on its own line
// the value needs quoting — so the per-rune charge is only visible // is too slow for the suite under the race detector; 4,096 at a time
// once it is amortised over a run of them. // is 271 batches, each logged on two lines, so 542 lines per handler.
const sampleRunes = 64 const batchRunes = 4096
// quotingSlack is that constant: the pair of quotes a handler adds to
// a value that needs them and omits from one that does not.
const quotingSlack = 2
// newHandlers are the two handlers internal/logger can install. Time // newHandlers are the two handlers internal/logger can install. Time
// is dropped so a line's width is a function of its value alone — // is dropped so a line's width is a function of its value alone —
@@ -66,46 +63,48 @@ func renderedWidth(
return buf.Len() return buf.Len()
} }
// chargeTestRunes is the set of code points the charge test measures: // emittedBytes is what a handler writes for the runes of s alone, in a
// every rune in the first two planes' worth of the BMP that the // value that starts with prefix: the width of a line carrying prefix
// handlers are most likely to treat specially, the separators that // and then s twice, less that of a line carrying prefix and s once.
// only slog's JSON handler escapes, and a stratified sample across // Both values start the same way and hold the same runes, so the text
// the rest of Unicode so the astral charge is exercised on more than // handler quotes both or neither, and the quotes cancel along with the
// one hand-picked rune. // prefix and everything else on the line.
func chargeTestRunes() []rune { func emittedBytes(
const ( newHandler func(io.Writer) slog.Handler,
denseCeiling = 0x800 prefix, s string,
stride = 1021 ) int {
surrogateLo = 0xD800 return renderedWidth(newHandler, prefix+s+s) -
surrogateHi = 0xDFFF renderedWidth(newHandler, prefix+s)
) }
var runes []rune // firstUndercharged returns the first rune in s that the handler
// writes in more bytes than EncodedBytes charges for it, and how many
// runes in s are undercharged that way. It measures one rune per line,
// in a value of that rune alone and again after a space, which makes
// the text handler quote the value. The charge test calls it on the
// code points below U+1000, and from there up only on a batch that has
// already failed, to name the code points rather than just their range.
func firstUndercharged(
newHandler func(io.Writer) slog.Handler,
s string,
) (rune, int) {
first, count := rune(-1), 0
keep := func(r rune) { for _, r := range s {
if r >= surrogateLo && r <= surrogateHi { charge := logfield.EncodedBytes(r)
return if emittedBytes(newHandler, "", string(r)) <= charge &&
emittedBytes(newHandler, " ", string(r)) <= charge {
continue
} }
runes = append(runes, r) if count == 0 {
first = r
} }
for r := range rune(denseCeiling) { count++
keep(r)
} }
for _, r := range []rune{ return first, count
0x2028, 0x2029, 0x200B, 0x4E00, 0xE000, 0xFFFD,
0x1000C, 0x1F600, 0xE0001, 0x10FFFF,
} {
keep(r)
}
for r := rune(denseCeiling); r <= utf8.MaxRune; r += stride {
keep(r)
}
return runes
} }
// TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit is the property // TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit is the property
@@ -114,33 +113,93 @@ func chargeTestRunes() []rune {
// how a stated ceiling becomes false without any test noticing, so // how a stated ceiling becomes false without any test noticing, so
// the charge is measured against what the handlers actually write // the charge is measured against what the handlers actually write
// rather than against the escaping rules as read. // rather than against the escaping rules as read.
//
// Every code point below U+1000 is checked on its own, for both
// handlers. That range holds the quote, the backslash and the control
// characters the handlers escape, next to code points each handler
// writes in fewer bytes than their charge, which in a sum would cover
// a neighbour charged too little. Each is measured in a value of it
// alone and again in one the text handler quotes, because that handler
// writes U+007F as one raw byte in a value it leaves bare but as \x7f,
// four bytes, in one it quotes.
//
// From U+1000 up the text handler writes every code point in exactly
// its charge, so the rest of Unicode is checked batchRunes at a time:
// each batch's summed charge must cover what the handler writes for
// the whole batch. The sums there can miss the JSON handler alone
// writing one code point in more bytes than its charge, when it writes
// others in the same batch in fewer.
func TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit(t *testing.T) { func TestEncodedBytes_ChargesAtLeastWhatTheHandlersEmit(t *testing.T) {
t.Parallel() t.Parallel()
var below strings.Builder
for r := range rune(0x1000) {
below.WriteRune(r)
}
var batches []string
for lo := rune(0x1000); lo <= utf8.MaxRune; lo += batchRunes {
var batch strings.Builder
for r := lo; r < lo+batchRunes; r++ {
// Surrogate halves are not runes a string can carry.
if utf8.ValidRune(r) {
batch.WriteRune(r)
}
}
batches = append(batches, batch.String())
}
// What EncodedBytes charges for each batch. Under -race -cover this
// takes longer than logging the batches, so it is worked out once,
// by whichever handler finishes logging first, while the other is
// still logging.
charged := sync.OnceValue(func() []int {
costs := make([]int, len(batches))
for i, batch := range batches {
for _, r := range batch {
costs[i] += logfield.EncodedBytes(r)
}
}
return costs
})
for name, newHandler := range newHandlers() { for name, newHandler := range newHandlers() {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
// 'a' is a printable ASCII rune, charged exactly one if first, count := firstUndercharged(newHandler, below.String()); count > 0 {
// byte, so it is the zero point the other runes are t.Errorf(
// measured against. "%d code points below U+1000 cost more than "+
base := renderedWidth( "EncodedBytes charges, the first U+%04X",
newHandler, strings.Repeat("a", sampleRunes), count, first,
) )
}
for _, r := range chargeTestRunes() { emitted := make([]int, len(batches))
got := renderedWidth( for i, batch := range batches {
newHandler, emitted[i] = emittedBytes(newHandler, "", batch)
strings.Repeat(string(r), sampleRunes), }
for i, cost := range charged() {
if emitted[i] <= cost {
continue
}
lo := rune(0x1000 + i*batchRunes)
first, count := firstUndercharged(
newHandler, batches[i],
) )
charged := sampleRunes * t.Errorf(
(logfield.EncodedBytes(r) - 1) "U+%04X to U+%04X emit %d bytes but are "+
"charged %d; %d of them cost more than "+
require.LessOrEqual( "EncodedBytes charges, the first U+%04X",
t, got-base, charged+quotingSlack, lo, lo+batchRunes-1, emitted[i], cost,
"U+%04X costs more on the line than "+ count, first,
"EncodedBytes charges for it",
r,
) )
} }
}) })
+4 -1
View File
@@ -600,7 +600,10 @@ func bodyLimitedMethod(method string) bool {
} }
// MaxBodySize returns middleware that limits the size of // MaxBodySize returns middleware that limits the size of
// POST/PUT/PATCH request bodies to maxBytes. It must be registered // POST/PUT/PATCH request bodies to maxBytes. A request with any other
// method passes through uncapped, deliberately: no route behind it
// reads a body on GET, HEAD or DELETE. A handler that starts to needs
// its method added to bodyLimitedMethod first. It must be registered
// before any middleware that parses the body — notably CSRF, which // before any middleware that parses the body — notably CSRF, which
// calls r.PostFormValue — so that form parsing happens under this // calls r.PostFormValue — so that form parsing happens under this
// cap rather than net/http's 10 MB default. // cap rather than net/http's 10 MB default.
+6 -4
View File
@@ -730,10 +730,8 @@ func TestNoCache_SetsHeaders(t *testing.T) {
const testBodyLimit int64 = 64 const testBodyLimit int64 = 64
// maxBodySizeHandler wraps a sentinel handler in MaxBodySize with // maxBodySizeResult is what runMaxBodySize's sentinel handler saw,
// testBodyLimit. The sentinel records whether it ran and how much of // together with the response.
// the body it managed to read, so tests can distinguish "never
// reached" from "reached but truncated".
type maxBodySizeResult struct { type maxBodySizeResult struct {
called bool called bool
read int read int
@@ -741,6 +739,10 @@ type maxBodySizeResult struct {
response *httptest.ResponseRecorder response *httptest.ResponseRecorder
} }
// runMaxBodySize wraps a sentinel handler in MaxBodySize with
// testBodyLimit and serves req through it. The sentinel records
// whether it ran and how much of the body it managed to read, so
// tests can distinguish "never reached" from "reached but truncated".
func runMaxBodySize( func runMaxBodySize(
t *testing.T, t *testing.T,
req *http.Request, req *http.Request,
+37 -1
View File
@@ -17,6 +17,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/chromedp/cdproto/browser"
"github.com/chromedp/cdproto/log" "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime" "github.com/chromedp/cdproto/runtime"
@@ -43,7 +44,7 @@ const (
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the // TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
// event log in a headless browser, served by the real router and so // event log in a headless browser, served by the real router and so
// under the real Content-Security-Policy, and checks that the pages' // under the real Content-Security-Policy, and checks that the pages'
// Alpine.js directives work. // Alpine.js directives and the copy control work.
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
t.Parallel() t.Parallel()
@@ -55,6 +56,13 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
userID, _ := env.seedUser(t, "browser", "browser-password") userID, _ := env.seedUser(t, "browser", "browser-password")
webhook := env.seedWebhook(t, userID) webhook := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{
WebhookID: webhook.ID,
Path: "3c9e1f7a-5b2d-4e8a-9f6c-2a7d1e4b8c05",
Active: true,
},
).Error)
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`) event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
target := env.seedTarget(t, webhook.ID) target := env.seedTarget(t, webhook.ID)
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID) dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
@@ -77,6 +85,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
checkAddForms(ctx, t, page) checkAddForms(ctx, t, page)
checkTargetType(ctx, t, page+"/events") checkTargetType(ctx, t, page+"/events")
checkCopy(ctx, t, page)
checkEventLog(ctx, t, page+"/events", event.ID, target.Name) checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
checkMobileMenu(ctx, t, page) checkMobileMenu(ctx, t, page)
@@ -220,6 +229,8 @@ func click(ctx context.Context, t *testing.T, xpath string) {
// checkAddForms loads a webhook page and checks that each section's add // checkAddForms loads a webhook page and checks that each section's add
// form stays hidden until the Add button beside its heading is clicked. // form stays hidden until the Add button beside its heading is clicked.
// The click looks for a button element there, so it also checks that
// Add is one.
func checkAddForms(ctx context.Context, t *testing.T, url string) { func checkAddForms(ctx context.Context, t *testing.T, url string) {
t.Helper() t.Helper()
@@ -327,6 +338,31 @@ func checkTargetType(ctx context.Context, t *testing.T, elsewhere string) {
) )
} }
// checkCopy loads a webhook page and checks that the Copy control beside
// its entrypoint's URL is a button, and that clicking it copies the URL
// and says so: the button reads "Copied" only once the copy succeeded.
func checkCopy(ctx context.Context, t *testing.T, url string) {
t.Helper()
copyButton := `//button[@data-copy-target]`
// A browser lets the page in its active tab write to the clipboard
// on a click. A headless browser refuses unless told to allow it.
require.NoError(t, chromedp.Run(
ctx,
browser.SetPermission(
&browser.PermissionDescriptor{Name: "clipboard-write"},
browser.PermissionSettingGranted,
),
loadPage(url),
))
click(ctx, t, copyButton)
assert.True(t, shown(ctx, copyButton+`[text()="Copied"]`),
`clicking Copy does not show "Copied"`)
}
// checkEventLog loads the event log and checks that clicking an event's // checkEventLog loads the event log and checks that clicking an event's
// row expands it, that in there clicking its delivery shows the // row expands it, that in there clicking its delivery shows the
// delivery's attempts and clicking again hides them, and that clicking // delivery's attempts and clicking again hides them, and that clicking
+2 -2
View File
@@ -191,7 +191,7 @@ func TestErrorPage_PanicOnAdminPage(t *testing.T) {
w := serve( w := serve(
server.NewRouterWithPageProbeForTest( server.NewRouterWithPageProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd, t, env.log, env.cfg, env.mw, env.hnd,
true, panicProbeHandler, true, panicProbeHandler,
), ),
server.PageProbePattern, server.PageProbePattern,
@@ -200,7 +200,7 @@ func TestErrorPage_PanicOnAdminPage(t *testing.T) {
w = serve( w = serve(
server.NewRouterWithProbeForTest( server.NewRouterWithProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd, t, env.log, env.cfg, env.mw, env.hnd,
true, panicProbeHandler, true, panicProbeHandler,
), ),
server.ProbePattern, server.ProbePattern,
+47 -26
View File
@@ -1,13 +1,16 @@
package server package server
import ( import (
"log/slog"
"net/http" "net/http"
"testing"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
) )
@@ -34,23 +37,45 @@ func SentryClientOptionsForTest(
return sentryClientOptions(dsn, release) return sentryClientOptions(dsn, release)
} }
// newServerForTest builds a Server through New, as the application
// does, on a lifecycle that is never started: the hooks New adds to
// it never run, so nothing listens.
func newServerForTest(
t *testing.T,
log *logger.Logger,
cfg *config.Config,
mw *middleware.Middleware,
h *handlers.Handlers,
) *Server {
t.Helper()
s, err := New(fxtest.NewLifecycle(t), ServerParams{
Logger: log,
Config: cfg,
Middleware: mw,
Handlers: h,
})
require.NoError(t, err)
return s
}
// NewRouterForTest builds the real route tree via SetupRoutes with // NewRouterForTest builds the real route tree via SetupRoutes with
// the supplied middleware and handlers, bypassing the fx lifecycle // the supplied middleware and handlers, on a Server from New whose
// and the HTTP listener. Tests use it so that route-group middleware // lifecycle is never started, so no HTTP listener runs. Tests use it
// registration order is exercised exactly as it ships, rather than // so that route-group middleware registration order is exercised
// against a hand-rebuilt chain that could drift from routes.go. // exactly as it ships, rather than against a hand-rebuilt chain that
// could drift from routes.go.
func NewRouterForTest( func NewRouterForTest(
log *slog.Logger, t *testing.T,
log *logger.Logger,
cfg *config.Config, cfg *config.Config,
mw *middleware.Middleware, mw *middleware.Middleware,
h *handlers.Handlers, h *handlers.Handlers,
) http.Handler { ) http.Handler {
s := &Server{ t.Helper()
log: log,
mw: mw, s := newServerForTest(t, log, cfg, mw, h)
h: h,
params: ServerParams{Config: cfg},
}
s.SetupRoutes() s.SetupRoutes()
return s.router return s.router
@@ -83,19 +108,17 @@ const ProbePattern = "/probe"
// option and the recoverer registered outside it is the thing a test // option and the recoverer registered outside it is the thing a test
// has to be able to pin. // has to be able to pin.
func NewRouterWithProbeForTest( func NewRouterWithProbeForTest(
log *slog.Logger, t *testing.T,
log *logger.Logger,
cfg *config.Config, cfg *config.Config,
mw *middleware.Middleware, mw *middleware.Middleware,
h *handlers.Handlers, h *handlers.Handlers,
sentryEnabled bool, sentryEnabled bool,
probe http.HandlerFunc, probe http.HandlerFunc,
) http.Handler { ) http.Handler {
s := &Server{ t.Helper()
log: log,
mw: mw, s := newServerForTest(t, log, cfg, mw, h)
h: h,
params: ServerParams{Config: cfg},
}
s.sentryEnabled.Store(sentryEnabled) s.sentryEnabled.Store(sentryEnabled)
s.SetupRoutes() s.SetupRoutes()
s.router.Handle(ProbePattern, probe) s.router.Handle(ProbePattern, probe)
@@ -113,19 +136,17 @@ const PageProbePattern = "/pages/probe"
// it, so the probe runs behind that group's own middleware exactly as // it, so the probe runs behind that group's own middleware exactly as
// the group's real routes do. // the group's real routes do.
func NewRouterWithPageProbeForTest( func NewRouterWithPageProbeForTest(
log *slog.Logger, t *testing.T,
log *logger.Logger,
cfg *config.Config, cfg *config.Config,
mw *middleware.Middleware, mw *middleware.Middleware,
h *handlers.Handlers, h *handlers.Handlers,
sentryEnabled bool, sentryEnabled bool,
probe http.HandlerFunc, probe http.HandlerFunc,
) http.Handler { ) http.Handler {
s := &Server{ t.Helper()
log: log,
mw: mw, s := newServerForTest(t, log, cfg, mw, h)
h: h,
params: ServerParams{Config: cfg},
}
s.sentryEnabled.Store(sentryEnabled) s.sentryEnabled.Store(sentryEnabled)
s.SetupRoutes() s.SetupRoutes()
+2 -2
View File
@@ -199,7 +199,7 @@ func TestPanicProbeChild(t *testing.T) {
env := newTestEnv(t) env := newTestEnv(t)
router := server.NewRouterWithProbeForTest( router := server.NewRouterWithProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd, t, env.log, env.cfg, env.mw, env.hnd,
false, panicProbeHandler, false, panicProbeHandler,
) )
@@ -253,7 +253,7 @@ func TestSentryStillSeesAPanic(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
router := server.NewRouterWithProbeForTest( router := server.NewRouterWithProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd, t, env.log, env.cfg, env.mw, env.hnd,
true, panicProbeHandler, true, panicProbeHandler,
) )
+2 -2
View File
@@ -67,11 +67,11 @@ func TestResponseControllerThroughProductionRouter(t *testing.T) {
routers := map[string]http.Handler{ routers := map[string]http.Handler{
server.ProbePattern: server.NewRouterWithProbeForTest( server.ProbePattern: server.NewRouterWithProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd, t, env.log, env.cfg, env.mw, env.hnd,
tc.sentryEnabled, probe, tc.sentryEnabled, probe,
), ),
server.PageProbePattern: server.NewRouterWithPageProbeForTest( server.PageProbePattern: server.NewRouterWithPageProbeForTest(
env.log.Get(), env.cfg, env.mw, env.hnd, t, env.log, env.cfg, env.mw, env.hnd,
tc.sentryEnabled, probe, tc.sentryEnabled, probe,
), ),
} }
+46 -4
View File
@@ -136,7 +136,7 @@ func newTestEnvWithConfig(
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
return &testEnv{ return &testEnv{
router: server.NewRouterForTest(log.Get(), cfg, mw, hnd), router: server.NewRouterForTest(t, log, cfg, mw, hnd),
sess: sess, sess: sess,
db: db, db: db,
dbMgr: dbMgr, dbMgr: dbMgr,
@@ -531,6 +531,48 @@ func TestStaticServesOnlyGetAndHead(t *testing.T) {
} }
} }
// --- every page route group ---
// TestPageRouteGroups_OversizeBody_RejectedBeforeCSRF pins the body
// cap ahead of CSRF and RequireAuth in every page route group. The
// requests carry no session and no CSRF token, so if either ran first
// the answer would be a 403 or a redirect to the login page rather
// than 413, and CSRF would issue its cookie (see
// TestPagesLogin_UnderLimit_NoToken_CSRFRejects). /settings has no
// POST route, but its group's middleware runs before the method is
// matched, so a POST there still reaches CSRF's form parsing if the
// cap moves after it. The user and webhook in the paths need not
// exist: nothing after the cap runs.
func TestPageRouteGroups_OversizeBody_RejectedBeforeCSRF(
t *testing.T,
) {
t.Parallel()
env := newTestEnv(t)
form := url.Values{}
form.Set("name", oversizeValue())
for _, path := range []string{
"/pages/login",
"/user/nobody/password",
"/settings/",
"/hooks/new",
"/hook/nonexistent/edit",
} {
w := env.post(path, form, nil)
assert.Equal(
t, http.StatusRequestEntityTooLarge, w.Code, path,
)
assert.False(
t, csrfCookieSet(w),
"CSRF middleware must not run for an oversized body to %s",
path,
)
}
}
// --- /pages group --- // --- /pages group ---
// TestPagesLogin_OversizeBody_RejectedBeforeCSRF proves the cap runs // TestPagesLogin_OversizeBody_RejectedBeforeCSRF proves the cap runs
@@ -1178,12 +1220,12 @@ func TestHook_LinksBetweenPages(t *testing.T) {
// mobile menu link. // mobile menu link.
{ {
"/user/navigator/", "/user/navigator/",
`href="([^"]+)" class="btn-text">Webhooks<`, `href="([^"]+)" class="btn-secondary">Webhooks<`,
list, list,
}, },
{ {
"/user/navigator/", "/user/navigator/",
`href="([^"]+)" class="btn-text w-full[^"]*">Webhooks<`, `href="([^"]+)" class="btn-secondary w-full">Webhooks<`,
list, list,
}, },
{list, `href="(/hook/[^"]+)"`, page}, {list, `href="(/hook/[^"]+)"`, page},
@@ -1610,7 +1652,7 @@ func TestTwoMetricsRoutersInOneProcess(t *testing.T) {
) )
third := &testEnv{ third := &testEnv{
router: server.NewRouterForTest( router: server.NewRouterForTest(
first.log.Get(), first.cfg, first.mw, first.hnd, t, first.log, first.cfg, first.mw, first.hnd,
), ),
} }
+48 -1
View File
@@ -1 +1,48 @@
/* Webhooker custom styles — see input.css for Tailwind theme */ /*
* The two shared styles for the controls a user clicks. Every page loads
* this file after tailwind.css. It is plain CSS: make css does not build
* it.
*
* A button is btn-primary, btn-secondary or btn-danger, from input.css.
* A secondary or inline action, such as Copy beside an entrypoint URL or
* Edit beside a target, is btn-small.
*
* Each card on the webhook list is a card-elevated link as a whole. It
* shows an Open label in btn-small and takes btn-small's focus outline.
*
* The rules join tailwind.css's components layer, where input.css puts
* its own, so a utility class on an element still overrides them.
*/
@layer components {
/* input.css gives its buttons no pointer cursor. */
.btn-primary,
.btn-secondary,
.btn-danger {
cursor: pointer;
}
.btn-small {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border: 1px solid var(--color-gray-300);
border-radius: var(--radius-md);
background-color: var(--color-white);
color: var(--color-primary-700);
font-size: var(--text-xs);
line-height: 1rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
}
.btn-small:hover {
border-color: var(--color-primary-500);
background-color: var(--color-primary-50);
}
.btn-small:focus-visible,
.card-elevated:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
}
+2 -2
View File
@@ -22,9 +22,9 @@
<footer class="bg-gray-100 border-t border-gray-200 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] mt-8"> <footer class="bg-gray-100 border-t border-gray-200 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] mt-8">
<div class="max-w-6xl mx-auto px-8 py-6"> <div class="max-w-6xl mx-auto px-8 py-6">
<div class="text-center text-sm text-gray-500 font-mono font-light"> <div class="text-center text-sm text-gray-500 font-mono font-light">
<a href="https://git.eeqj.de/sneak/webhooker" class="hover:text-gray-700">Webhooker</a> <a href="https://git.eeqj.de/sneak/webhooker" class="btn-small">Webhooker</a>
<span class="mx-1">by</span> <span class="mx-1">by</span>
<a href="https://sneak.berlin" class="hover:text-gray-700">@sneak</a> <a href="https://sneak.berlin" class="btn-small">@sneak</a>
<span class="mx-3">|</span> <span class="mx-3">|</span>
<span>{{if .Version}}{{.Version}}{{else}}dev{{end}}</span> <span>{{if .Version}}{{.Version}}{{else}}dev{{end}}</span>
</div> </div>
+1
View File
@@ -3,6 +3,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Webhooker{{end}}</title> <title>{{block "title" .}}Webhooker{{end}}</title>
<link rel="stylesheet" href="/s/css/tailwind.css"> <link rel="stylesheet" href="/s/css/tailwind.css">
<link rel="stylesheet" href="/s/css/style.css">
<style>[x-cloak] { display: none !important; }</style> <style>[x-cloak] { display: none !important; }</style>
{{block "head" .}}{{end}} {{block "head" .}}{{end}}
{{end}} {{end}}
+10 -10
View File
@@ -2,12 +2,12 @@
<nav class="app-bar" x-data="collapsible"> <nav class="app-bar" x-data="collapsible">
<div class="max-w-6xl mx-auto flex justify-between items-center"> <div class="max-w-6xl mx-auto flex justify-between items-center">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">Webhooker</a> <a href="/" class="btn-secondary text-xl">Webhooker</a>
</div> </div>
<!-- Mobile menu button --> <!-- Mobile menu button -->
{{if .User}} {{if .User}}
<button @click="toggle" class="md:hidden p-2 rounded-md text-gray-500 hover:bg-gray-100"> <button type="button" @click="toggle" class="btn-secondary md:hidden p-2">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path x-show="closed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/> <path x-show="closed" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
<path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> <path x-show="open" x-cloak stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
@@ -18,9 +18,9 @@
<!-- Desktop navigation --> <!-- Desktop navigation -->
<div class="hidden md:flex items-center gap-4"> <div class="hidden md:flex items-center gap-4">
{{if .User}} {{if .User}}
<a href="/hooks" class="btn-text">Webhooks</a> <a href="/hooks" class="btn-secondary">Webhooks</a>
<a href="/settings" class="btn-text">Settings</a> <a href="/settings" class="btn-secondary">Settings</a>
<a href="/user/{{.User.Username}}" class="btn-text"> <a href="/user/{{.User.Username}}" class="btn-secondary">
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16"> <svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/> <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/> <path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
@@ -32,7 +32,7 @@
{{if .CSRFToken}} {{if .CSRFToken}}
<form method="POST" action="/pages/logout" class="inline"> <form method="POST" action="/pages/logout" class="inline">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text">Logout</button> <button type="submit" class="btn-secondary">Logout</button>
</form> </form>
{{end}} {{end}}
{{end}} {{end}}
@@ -43,13 +43,13 @@
<div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200"> <div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{{if .User}} {{if .User}}
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a> <a href="/hooks" class="btn-secondary w-full">Webhooks</a>
<a href="/settings" class="btn-text w-full text-left">Settings</a> <a href="/settings" class="btn-secondary w-full">Settings</a>
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a> <a href="/user/{{.User.Username}}" class="btn-secondary w-full">Profile</a>
{{if .CSRFToken}} {{if .CSRFToken}}
<form method="POST" action="/pages/logout"> <form method="POST" action="/pages/logout">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-text w-full text-left">Logout</button> <button type="submit" class="btn-secondary w-full">Logout</button>
</form> </form>
{{end}} {{end}}
{{end}} {{end}}
+16 -16
View File
@@ -9,7 +9,7 @@
no class this wide. --> no class this wide. -->
<div class="mx-auto px-6 py-8" style="max-width: 108rem"> <div class="mx-auto px-6 py-8" style="max-width: 108rem">
<div class="mb-6"> <div class="mb-6">
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a> <a href="/hooks" class="btn-small">&larr; Back to webhooks</a>
<div class="flex flex-wrap justify-between items-center gap-2 mt-2"> <div class="flex flex-wrap justify-between items-center gap-2 mt-2">
<div> <div>
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1> <h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
@@ -35,8 +35,8 @@
<div class="card" x-data="collapsible"> <div class="card" x-data="collapsible">
<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">Entrypoints</h2> <h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
<button @click="toggle" class="btn-text text-sm"> <button type="button" @click="toggle" class="btn-small">
<svg class="w-4 h-4 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>
Add Add
@@ -55,9 +55,9 @@
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Entrypoints}} {{range .Entrypoints}}
<div class="p-4"> <div class="p-4">
<div class="flex items-center justify-between mb-1"> <div class="flex flex-wrap items-center justify-between gap-2 mb-1">
<span class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span> <span class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
{{if .Active}} {{if .Active}}
<span class="badge-success">Active</span> <span class="badge-success">Active</span>
{{else}} {{else}}
@@ -65,13 +65,13 @@
{{end}} {{end}}
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}"> <button type="submit" class="btn-small" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
{{if .Active}}Deactivate{{else}}Activate{{end}} {{if .Active}}Deactivate{{else}}Activate{{end}}
</button> </button>
</form> </form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button> <button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
</form> </form>
</div> </div>
</div> </div>
@@ -79,7 +79,7 @@
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/h/{{.Path}}</code> <code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/h/{{.Path}}</code>
<!-- Hidden until app.js reveals it; without the <!-- Hidden until app.js reveals it; without the
script the URL above stays selectable. --> script the URL above stays selectable. -->
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button> <button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="btn-small">Copy</button>
</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. -->
@@ -94,8 +94,8 @@
<div class="card" x-data="collapsible"> <div class="card" x-data="collapsible">
<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 @click="toggle" class="btn-text text-sm"> <button type="button" @click="toggle" class="btn-small">
<svg class="w-4 h-4 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>
Add Add
@@ -148,25 +148,25 @@
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Targets}} {{range .Targets}}
<div class="p-4"> <div class="p-4">
<div class="flex items-center justify-between mb-1"> <div class="flex flex-wrap items-center justify-between gap-2 mb-1">
<span class="text-sm font-medium text-gray-900">{{.Name}}</span> <span class="text-sm font-medium text-gray-900">{{.Name}}</span>
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="badge-info">{{.Type}}</span> <span class="badge-info">{{.Type}}</span>
{{if .Active}} {{if .Active}}
<span class="badge-success">Active</span> <span class="badge-success">Active</span>
{{else}} {{else}}
<span class="badge-error">Inactive</span> <span class="badge-error">Inactive</span>
{{end}} {{end}}
<a href="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a> <a href="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="btn-small" title="Edit">Edit</a>
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}"> <button type="submit" class="btn-small" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
{{if .Active}}Deactivate{{else}}Activate{{end}} {{if .Active}}Deactivate{{else}}Activate{{end}}
</button> </button>
</form> </form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button> <button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
</form> </form>
</div> </div>
</div> </div>
@@ -188,7 +188,7 @@
<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">50 Most Recent Events</h2> <h2 class="text-lg font-medium text-gray-900">50 Most Recent Events</h2>
<a href="/hook/{{.Webhook.ID}}/events" class="btn-text text-sm">Full Event Log</a> <a href="/hook/{{.Webhook.ID}}/events" class="btn-small">Full Event Log</a>
</div> </div>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Events}} {{range .Events}}
+1 -1
View File
@@ -5,7 +5,7 @@
{{define "content"}} {{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8"> <div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a> <a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1> <h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Webhook</h1>
</div> </div>
+22 -20
View File
@@ -5,7 +5,7 @@
{{define "content"}} {{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8"> <div class="max-w-6xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a> <a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a>
<div class="flex justify-between items-center mt-2"> <div class="flex justify-between items-center mt-2">
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1> <h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span> <span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
@@ -16,8 +16,8 @@
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Events}} {{range .Events}}
<div class="p-4" x-data="collapsible"> <div class="p-4" x-data="collapsible">
<div class="flex items-center justify-between cursor-pointer" @click="toggle"> <button type="button" class="btn-small w-full flex flex-wrap justify-between gap-2 text-left" @click="toggle">
<div class="flex items-center gap-3"> <span class="flex flex-wrap items-center gap-3">
<span class="badge-info">{{.Method}}</span> <span class="badge-info">{{.Method}}</span>
<span class="text-sm font-mono text-gray-700">{{.ID}}</span> <span class="text-sm font-mono text-gray-700">{{.ID}}</span>
<span class="text-sm text-gray-500">{{.ContentType}}</span> <span class="text-sm text-gray-500">{{.ContentType}}</span>
@@ -27,8 +27,8 @@
{{if .ResubmitCount}} {{if .ResubmitCount}}
<span class="text-xs text-gray-500">resubmitted {{.ResubmitCount}} time{{if ne .ResubmitCount 1}}s{{end}}</span> <span class="text-xs text-gray-500">resubmitted {{.ResubmitCount}} time{{if ne .ResubmitCount 1}}s{{end}}</span>
{{end}} {{end}}
</div> </span>
<div class="flex items-center gap-4"> <span class="flex flex-wrap items-center gap-4">
{{range .Deliveries}} {{range .Deliveries}}
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}"> <span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">
{{.Target.DisplayName}}: {{.Status}} {{.Target.DisplayName}}: {{.Status}}
@@ -38,8 +38,8 @@
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg> </svg>
</div> </span>
</div> </button>
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md"> <div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2"> <div class="mb-3 flex flex-wrap items-center justify-between gap-2">
@@ -50,12 +50,12 @@
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="page" value="{{$.Page}}"> <input type="hidden" name="page" value="{{$.Page}}">
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Submit this event again as a new event, to every currently active target">Resubmit</button> <button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
</form> </form>
</div> </div>
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre> <pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
{{if .BodyTruncated}} {{if .BodyTruncated}}
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged &mdash; <a href="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p> <p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged &mdash; <a href="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="btn-small">download the full body</a>.</p>
{{end}} {{end}}
{{if .Deliveries}} {{if .Deliveries}}
@@ -64,24 +64,26 @@
<div class="mt-2 divide-y divide-gray-200"> <div class="mt-2 divide-y divide-gray-200">
{{range .Deliveries}} {{range .Deliveries}}
<div class="py-2" x-data="collapsible"> <div class="py-2" x-data="collapsible">
<div class="flex items-center justify-between cursor-pointer" @click="toggle">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button type="button" class="btn-small flex-1 flex-wrap justify-between gap-2 text-left" @click="toggle">
<span class="flex flex-wrap items-center gap-3">
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span> <span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span> <span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{.Status}}</span>
</div> </span>
<div class="flex items-center gap-3"> <span class="flex flex-wrap items-center gap-3">
{{if .Status.Terminal}}
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline" @click.stop>
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="page" value="{{$.Page}}">
<button type="submit" class="text-xs text-primary-600 hover:text-primary-700" title="Send this event to the target again">Replay</button>
</form>
{{end}}
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span> <span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg> </svg>
</div> </span>
</button>
{{if .Status.Terminal}}
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="page" value="{{$.Page}}">
<button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
</form>
{{end}}
</div> </div>
<div x-show="open" x-cloak class="mt-2 space-y-2"> <div x-show="open" x-cloak class="mt-2 space-y-2">
+5
View File
@@ -25,7 +25,12 @@
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p> <p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
{{end}} {{end}}
</div> </div>
<div class="flex flex-wrap items-center gap-2">
<span class="badge-info">Retention: {{.RetentionLabel}}</span> <span class="badge-info">Retention: {{.RetentionLabel}}</span>
<!-- A label, not a control of its own: the whole card
is the link. -->
<span class="btn-small">Open &rarr;</span>
</div>
</div> </div>
<div class="flex flex-wrap gap-6 mt-4 text-sm text-gray-500"> <div class="flex flex-wrap gap-6 mt-4 text-sm text-gray-500">
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}{{if .InactiveEntrypointCount}}, {{.InactiveEntrypointCount}} inactive{{end}}</span> <span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}{{if .InactiveEntrypointCount}}, {{.InactiveEntrypointCount}} inactive{{end}}</span>
+1 -1
View File
@@ -5,7 +5,7 @@
{{define "content"}} {{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8"> <div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a> <a href="/hooks" class="btn-small">&larr; Back to webhooks</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1> <h1 class="text-2xl font-medium text-gray-900 mt-2">Create Webhook</h1>
</div> </div>
+1 -1
View File
@@ -5,7 +5,7 @@
{{define "content"}} {{define "content"}}
<div class="max-w-2xl mx-auto px-6 py-8"> <div class="max-w-2xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to {{.Webhook.Name}}</a> <a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1> <h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
<p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p> <p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
</div> </div>