Compare commits

8 Commits
Author SHA1 Message Date
sneak 9b44189a91 Clamp the HTTP drain by the tail-hook reserve (closes #170)
check / check (push) Successful in 3m23s
The server's stop hook bounded the drain by ShutdownTimeout alone, so
once the archive sweeper or retention reaper had spent part of the fx
stop budget, a request held open could use up the reserve and fx
skipped every hook after the server, the database close included.
The drain now gets the shorter of ShutdownTimeout and what is left
less TailHookReserve, the clamp the Sentry flush already has.

The headroom test also sweeps the time earlier hooks spent and checks
that a drain on the full budget gets all of ShutdownTimeout. A new
test, run on synctest's clock, holds a request open over net.Pipe
against a stop context with only the reserve left. The README and the
reserve's comment say how the reserve is derived and what a slow
sweeper now costs.

Model: opus-5-5
2026-10-02 17:38:15 +00:00
clawbot f82b730c31 Pin the HTTP target's unpinned error checks (closes #285)
check / check (push) Successful in 3m20s
Seven error checks in internal/delivery/target_http.go could be removed without any test noticing, among them withRetry's check on writing the delivery result, the branch that leaves a sent delivery retrying and recoverable when its bookkeeping write fails. Each now fails a test when removed. The "send succeeded" case starts from a tripped circuit breaker, so a probe whose send succeeds but whose result write fails must still close the breaker. The checks in remainingBackoff and backoffElapsed stay unpinned: removing them gives the same answer, and they state a rule a reader needs. Test change only.

Model: opus-5-5
2026-10-02 19:37:36 +02:00
clawbot 1a1fee0874 Name the reaper's hard delete in the event body comments (closes #455)
check / check (push) Successful in 3m20s
The comments on eventBodyQuery and on TestHandleEventBodyDownload_ReapedEvent404s credited the soft-delete predicate for refusing a reaped event. The retention reaper deletes event rows outright and nothing soft-deletes an event, so a reaped event is simply gone. Both comments now say so; the test's "soft deleted" case is described as pinning the query's deleted_at predicate for a row no code produces today. Comments only.

Model: opus-5-5
2026-10-02 19:36:30 +02:00
clawbot 290925f184 Fix two resubmit comments and test the resubmit route's middleware (closes #252)
check / check (push) Successful in 3m18s
Two comments named the wrong mechanism: loadResubmitSource credited soft-delete for refusing a reaped event, though the retention reaper deletes event rows outright, and createAndFanOut claimed to be the only path that creates deliveries, though per-delivery replay creates one without an event. Both now say what the code does. The resubmit route's middleware had no tests through the router; new tests drive the production router to pin the refusal without a valid CSRF token, the rate limit, signed-out requests never spending it, and another webhook's event refused by the event lookup while the user's own event is accepted. Each fails with its check removed.

Model: opus-5-5
2026-10-02 19:20:40 +02:00
clawbot 73353bc8e5 Harden the (*gorm.DB).Scan guard test (closes #232)
check / check (push) Successful in 3m17s
The test that refuses production calls to (*gorm.DB).Scan, the one GORM path that bypasses the logger's value suppression, overstated what it checks and could pass while skipping a whole package. Its comments now say it matches receiver method names, not types, and name the evasion this leaves; GORM's Rows is dropped from the accepted names. Method values are stated as out of scope with the reason. The file-count floor is replaced by a check that every package the walk parses, static and templates included, was reached. The planted snippets are valid Go and cover each receiver form the guard claims to handle. Test change only.

Model: opus-5-5
2026-10-02 19:19:45 +02:00
clawbot 40f59ec4d2 Try every event database file on delete and say which was left (closes #275)
check / check (push) Successful in 3m20s
DeleteDB returned on the first event database file it failed to remove, so with WAL sidecars present a failure could be reported as "the file was left behind" after the events themselves were already gone. It now tries the database file and both sidecars, and its error says which case happened and names each file left: the database file is still on disk, or it is gone (the events are lost) and only a -wal or -shm sidecar remains. The webhook delete handler logs a different message for each and never says the events survive when they do not. Tests cover a normal delete with both sidecars, and a failed removal of the database file or a sidecar, through DeleteDB and the handler.

Model: opus-5-5
2026-10-02 18:42:29 +02:00
clawbot 806c95e305 Make every clickable control look clickable, in two shared styles (closes #375)
check / check (push) Successful in 3m16s
Many controls were plain coloured text with no sign they could be clicked: Edit, Activate, Deactivate, Delete, Replay, Resubmit, both Add controls and the copy control beside each entrypoint URL. Every clickable control is now a real button or link in one of two shared styles: the buttons in input.css, now with a pointer cursor, and btn-small, a small bordered secondary action in style.css, which the layout now loads. The site name, the footer links and each webhook list card show at rest that they open something, with focus states. The copy control shows "Copied" after a click. Rows on the webhook page and in the event log wrap at phone width. The browser test covers the copy control.

Model: opus-5-5
2026-10-02 18:32:19 +02:00
clawbot 45bd7e9b94 Pin the close before the reopen in the archive sweep (closes #103)
check / check (push) Successful in 3m35s
The archive sweep closes a target's archive connection before it reopens the file, and no test noticed if that close was removed, which would leak one SQLite connection per target per sweep. A test now keeps the connection from before a sweep that reopens the archive and requires it closed afterwards. The sweeper's query for database targets takes the sweep's context; a sweep whose context is done returns without an error line, so stopping is not logged as a failure, and a test pins that. The comment on the sweeper's cancel function gives the true reason it needs no lock: fx calls the stop hook only after the start hook has returned.

Model: opus-5-5
2026-10-02 18:16:31 +02:00
36 changed files with 1307 additions and 187 deletions
+35 -27
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.)
@@ -3244,9 +3245,9 @@ each hook. The order, read off the fx stop-hook log:
1. `ArchiveSweeper` 1. `ArchiveSweeper`
2. `RetentionReaper` 2. `RetentionReaper`
3. `server` — the HTTP drain, bounded separately by 3. `server` — the HTTP drain, bounded by `server.ShutdownTimeout`
`server.ShutdownTimeout` (**3 seconds**), then a Sentry flush if (**3 seconds**) and by what the hooks before it left, then a Sentry
`SENTRY_DSN` is set flush if `SENTRY_DSN` is set
4. `delivery.Engine` — waits for its workers, then closes the archive 4. `delivery.Engine` — waits for its workers, then closes the archive
databases databases
5. `healthcheck` 5. `healthcheck`
@@ -3266,23 +3267,30 @@ exhaust the sequence budget at the instant it finished, and every
later hook — the delivery engine, the healthcheck, the webhook DB later hook — the delivery engine, the healthcheck, the webhook DB
manager and the database close — would be skipped in exactly the manager and the database close — would be skipped in exactly the
case where the drain mattered. 3 seconds leaves 2 seconds case where the drain mattered. 3 seconds leaves 2 seconds
(`server.TailHookReserve`) for the tail, which is far more than the (`server.TailHookReserve`) for the tail. The reserve is that
microseconds it needs. remainder, not a figure sized to the tail, which takes about a
millisecond.
That reserve belongs to the tail hooks, not to the server hook, and That reserve belongs to the tail hooks, not to the server hook, and
the Sentry flush is what could take it: it runs after the drain the server hook could take it in two ways. The hooks before it may
**inside the same hook**, and `sentry.Flush` takes a bare duration already have spent part of the budget, so a full 3-second drain
and honours no context, so an unreachable Sentry endpoint would add would come out of the reserve; the drain is therefore also bounded
its own timeout on top of a full-length drain and consume the whole by whatever is left on the stop context minus the reserve. And the
sequence budget by itself. It is therefore clamped to whatever is Sentry flush runs after the drain **inside the same hook**, and
left on the stop context minus the reserve, and skipped when that `sentry.Flush` takes a bare duration and honours no context, so an
leaves too little to be worth attempting — so a full-length drain unreachable Sentry endpoint would add its own timeout on top of a
means Sentry events are dropped rather than the database close being full-length drain and consume the whole sequence budget by itself.
skipped. It is clamped the same way, and skipped when that leaves too little
to be worth attempting — so a full-length drain means Sentry events
are dropped rather than the database close being skipped.
This does not make the database close unconditional: a wedged This does not make the database close unconditional. A slow
`ArchiveSweeper` or `RetentionReaper` still runs first and can `ArchiveSweeper` or `RetentionReaper` is enough to cut the shutdown
consume the whole budget on its own. short, not only one that consumes the whole budget: what they spend
comes out of the drain first, so after 2 seconds of theirs a request
still in flight gets 1 second to finish, and after 3 it gets none.
Past 3 seconds they spend the reserve itself, and one that takes the
whole budget skips every hook after it, the database close included.
The value is chosen to sit inside the container stop grace period. The value is chosen to sit inside the container stop grace period.
Docker's default `docker stop` grace is 10 seconds and the Dockerfile Docker's default `docker stop` grace is 10 seconds and the Dockerfile
+9 -7
View File
@@ -38,17 +38,19 @@ import (
// hook that used the whole budget would exhaust it at that instant, // hook that used the whole budget would exhaust it at that instant,
// and fx would skip every hook after the server — the delivery // and fx would skip every hook after the server — the delivery
// engine, the healthcheck, the webhook DB manager and the database // engine, the healthcheck, the webhook DB manager and the database
// close. That hook is the 3s HTTP drain plus the Sentry flush that // close. That hook is the HTTP drain plus the Sentry flush that
// follows it in the same hook, so the flush is clamped to the stop // follows it in the same hook, and each is clamped to the stop
// context's remaining time less server.TailHookReserve rather than // context's remaining time less server.TailHookReserve rather than
// running for its own fixed 2s; the reserve is what the tail hooks // running for its own fixed 3s and 2s; the reserve is what the tail
// live on, and they are microsecond-scale in normal operation. // hooks live on, and they are microsecond-scale in normal operation.
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic // TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
// across every drain length. // across every drain length and every amount of budget the hooks
// before the server may already have spent.
// //
// This does not make the database close unconditional: the // This does not make the database close unconditional: the
// ArchiveSweeper and RetentionReaper hooks run before the server // ArchiveSweeper and RetentionReaper hooks run before the server.
// and can still consume the whole budget on their own. // What they spend comes out of the drain first, but past 3s it comes
// out of the reserve, and they can consume the whole budget.
const stopTimeout = 5 * time.Second const stopTimeout = 5 * time.Second
// exitUsage is the status for a command line this binary cannot make // exitUsage is the status for a command line this binary cannot make
+26 -8
View File
@@ -252,22 +252,40 @@ const tailHeadroom = 2 * time.Second
// can produce, since a shorter drain leaves the flush more room and // can produce, since a shorter drain leaves the flush more room and
// the worst case is not necessarily at either extreme. // the worst case is not necessarily at either extreme.
// //
// Shrinking either budget, or unbounding the flush again, must fail // Nor does the hook start on a full budget: the ArchiveSweeper and
// here rather than silently recreating a hook that swallows the // RetentionReaper hooks run before it, and whatever they spent is
// whole sequence. // gone. The outer sweep walks every amount they can spend. Once they
// have eaten into the headroom themselves, the hook must spend
// nothing of what is left. A drain that starts on the full budget
// must still get all of ShutdownTimeout, so a smaller stopTimeout
// cannot silently shorten every drain.
//
// Shrinking either budget, or unbounding the drain or the flush
// again, must fail here rather than silently recreating a hook that
// swallows the whole sequence.
func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) { func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) {
t.Parallel() t.Parallel()
require.Less(t, server.ShutdownTimeout, stopTimeout) require.Less(t, server.ShutdownTimeout, stopTimeout)
require.Equal(
t, server.ShutdownTimeout, server.DrainBudget(stopTimeout),
"a drain that starts on the full stop budget is cut short",
)
const step = 10 * time.Millisecond const step = 10 * time.Millisecond
for drain := time.Duration(0); drain <= server.ShutdownTimeout; drain += step { for spent := time.Duration(0); spent <= stopTimeout; spent += step {
hook := drain + server.SentryFlushBudget(stopTimeout-drain) remaining := stopTimeout - spent
longest := max(server.DrainBudget(remaining), 0)
require.LessOrEqual( for drain := time.Duration(0); drain <= longest; drain += step {
t, hook+tailHeadroom, stopTimeout, hook := drain + server.SentryFlushBudget(remaining-drain)
"a %s drain leaves the tail hooks short", drain,
require.GreaterOrEqual(
t, remaining-hook, min(remaining, tailHeadroom),
"a %s drain after %s of earlier hooks leaves "+
"the tail hooks short", drain, spent,
) )
} }
} }
}
+41 -7
View File
@@ -33,6 +33,19 @@ var errInvalidCachedDBType = errors.New(
"invalid cached database type", "invalid cached database type",
) )
// ErrEventDBNotRemoved is in DeleteDB's error when the event
// database file itself could not be removed: it is still on disk.
var ErrEventDBNotRemoved = errors.New(
"event database file not removed",
)
// ErrSidecarNotRemoved is in DeleteDB's error when the event
// database file was removed, so its events are gone, but its -wal
// or -shm sidecar could not be.
var ErrSidecarNotRemoved = errors.New(
"event database file removed, but a -wal or -shm sidecar was not",
)
// WebhookDBManager manages per-webhook SQLite database files // WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated // for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, DeliveryResults and the // database containing Events, Deliveries, DeliveryResults and the
@@ -151,7 +164,10 @@ func (m *WebhookDBManager) DBExists(
} }
// DeleteDB closes the connection and deletes the database file // DeleteDB closes the connection and deletes the database file
// for a webhook. The file is permanently removed. // for a webhook, with its -wal and -shm sidecars. The files are
// permanently removed. Each file is tried even when another could
// not be removed, and the error wraps ErrEventDBNotRemoved or
// ErrSidecarNotRemoved to say which was left, naming each file.
func (m *WebhookDBManager) DeleteDB( func (m *WebhookDBManager) DeleteDB(
webhookID string, webhookID string,
) error { ) error {
@@ -170,16 +186,23 @@ func (m *WebhookDBManager) DeleteDB(
} }
} }
// Delete the main DB file and WAL/SHM files
path := m.dbPath(webhookID) path := m.dbPath(webhookID)
for _, suffix := range []string{"", "-wal", "-shm"} {
err := os.Remove(path + suffix) dbErr := removeFile(path)
if err != nil && !os.IsNotExist(err) { sidecarErr := errors.Join(
removeFile(path+"-wal"),
removeFile(path+"-shm"),
)
if dbErr != nil {
return fmt.Errorf( return fmt.Errorf(
"deleting webhook database file %s%s: %w", "%w: %w",
path, suffix, err, ErrEventDBNotRemoved, errors.Join(dbErr, sidecarErr),
) )
} }
if sidecarErr != nil {
return fmt.Errorf("%w: %w", ErrSidecarNotRemoved, sidecarErr)
} }
m.log.Info( m.log.Info(
@@ -190,6 +213,17 @@ func (m *WebhookDBManager) DeleteDB(
return nil return nil
} }
// removeFile removes path. A file that is already gone counts as
// removed; the error from any other failure names the file.
func removeFile(path string) error {
err := os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// CloseAll closes all open per-webhook database connections. // CloseAll closes all open per-webhook database connections.
// Called during application shutdown. // Called during application shutdown.
func (m *WebhookDBManager) CloseAll() error { func (m *WebhookDBManager) CloseAll() error {
+77 -3
View File
@@ -182,17 +182,91 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
} }
require.NoError(t, db.Create(event).Error) require.NoError(t, db.Create(event).Error)
// Under WAL, an open database that has been written to has both
// sidecars beside it.
dbPath := mgr.DBPath(webhookID)
require.FileExists(t, dbPath+"-wal")
require.FileExists(t, dbPath+"-shm")
// Delete the DB // Delete the DB
require.NoError(t, mgr.DeleteDB(webhookID)) require.NoError(t, mgr.DeleteDB(webhookID))
// File should no longer exist // File should no longer exist
assert.False(t, mgr.DBExists(webhookID)) assert.False(t, mgr.DBExists(webhookID))
// Verify the file is actually gone from disk // Verify the files are actually gone from disk
assert.NoFileExists(t, dbPath)
assert.NoFileExists(t, dbPath+"-wal")
assert.NoFileExists(t, dbPath+"-shm")
}
// blockRemoval puts a non-empty directory at path, which os.Remove
// cannot remove whoever runs the test, root included.
func blockRemoval(t *testing.T, path string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(path, "keep"), 0o700))
}
// TestWebhookDBManager_DeleteDBKeepsDatabaseFile proves that when the
// event database file cannot be removed, the error says so, and both
// sidecars are still removed.
func TestWebhookDBManager_DeleteDBKeepsDatabaseFile(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
dbPath := mgr.DBPath(webhookID) dbPath := mgr.DBPath(webhookID)
_, err = os.Stat(dbPath) blockRemoval(t, dbPath)
assert.True(t, os.IsNotExist(err)) require.NoError(t, os.WriteFile(dbPath+"-wal", nil, 0o600))
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
err := mgr.DeleteDB(webhookID)
require.ErrorIs(t, err, database.ErrEventDBNotRemoved)
require.NotErrorIs(t, err, database.ErrSidecarNotRemoved)
assert.Contains(t, err.Error(), dbPath)
assert.NoFileExists(t, dbPath+"-wal")
assert.NoFileExists(t, dbPath+"-shm")
}
// TestWebhookDBManager_DeleteDBKeepsSidecar proves that when the
// event database file is removed but a sidecar is not, the error
// says the database file is gone, and the other sidecar is still
// removed.
func TestWebhookDBManager_DeleteDBKeepsSidecar(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
dbPath := mgr.DBPath(webhookID)
require.NoError(t, mgr.CreateDB(webhookID))
// Closing removes the sidecars, so the ones below are the only
// ones there.
require.NoError(t, mgr.CloseAll())
blockRemoval(t, dbPath+"-wal")
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
err := mgr.DeleteDB(webhookID)
require.ErrorIs(t, err, database.ErrSidecarNotRemoved)
require.NotErrorIs(t, err, database.ErrEventDBNotRemoved)
assert.Contains(t, err.Error(), dbPath+"-wal")
assert.NoFileExists(t, dbPath)
assert.NoFileExists(t, dbPath+"-shm")
} }
func TestWebhookDBManager_LazyCreation(t *testing.T) { func TestWebhookDBManager_LazyCreation(t *testing.T) {
+13
View File
@@ -45,7 +45,12 @@ type ArchiveSweeper struct {
eng *Engine eng *Engine
log *slog.Logger log *slog.Logger
interval time.Duration interval time.Duration
// cancel needs no lock: fx calls the stop hook only after the
// start hook has returned, so stop never reads it while start
// is still setting it.
cancel context.CancelFunc cancel context.CancelFunc
wg sync.WaitGroup wg sync.WaitGroup
} }
@@ -163,10 +168,18 @@ func (s *ArchiveSweeper) sweep(ctx context.Context) {
var targets []database.Target var targets []database.Target
err := s.db.DB(). err := s.db.DB().
WithContext(ctx).
Model(&database.Target{}). Model(&database.Target{}).
Where("type = ?", database.TargetTypeDatabase). Where("type = ?", database.TargetTypeDatabase).
Find(&targets).Error Find(&targets).Error
if err != nil { if err != nil {
// The app stopping as a sweep starts cancels the listing.
// Stopping is not a failure, so it must not produce an
// error line.
if ctx.Err() != nil {
return
}
s.log.Error( s.log.Error(
"archive sweep: failed to list database targets", "archive sweep: failed to list database targets",
"error", err, "error", err,
+60
View File
@@ -1,9 +1,11 @@
package delivery_test package delivery_test
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -681,6 +683,64 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
) )
} }
// TestArchiveSweep_ClosesHandleBeforeReopening proves the sweep
// closes the handle it finds open before it reopens the file.
// TestArchiveSweep_LeavesArchiveClosed cannot see this: without the
// close, the reopen replaces the handle without closing it, the
// sweep then closes only the new one, and one connection leaks per
// archive per sweep.
func TestArchiveSweep_ClosesHandleBeforeReopening(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Open(time.Hour))
before, err := w.DB().DB()
require.NoError(t, err)
require.NoError(t, w.SweepExpired(time.Hour))
assert.Error(
t, before.PingContext(t.Context()),
"the handle open before the sweep must be closed by it",
)
}
// TestArchiveSweep_CancelledSweepLogsNoError proves a sweep whose
// context is already cancelled, as when the app stops just as a
// sweep starts, returns without an error line: stopping is not a
// failure.
func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
var errorLines bytes.Buffer
sweeper := delivery.NewTestArchiveSweeper(
env.mainDB, env.eng,
slog.New(slog.NewTextHandler(
&errorLines,
&slog.HandlerOptions{Level: slog.LevelError},
)),
)
ctx, cancel := context.WithCancel(context.Background())
cancel()
sweeper.ExportSweep(ctx)
assert.Empty(
t, errorLines.String(),
"a cancelled sweep must not log at error level",
)
}
// TestArchiveSweep_NeverExpiryUntouched proves the sweep is a // TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
// no-op for the default retention policy, so archives with no // no-op for the default retention policy, so archives with no
// expiry (or the literal "never") behave exactly as before. // expiry (or the literal "never") behave exactly as before.
@@ -1425,6 +1425,32 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) {
) )
} }
// TestDeliverHTTP_InvalidConfigUnrecordedStaysPending: a delivery is
// failed for an invalid config only once the reason is recorded.
// Unrecorded, it stays pending, where the sweep finds it again.
func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event, del := iSeedEventAndDelivery(
t, db, `{"config":"invalid"}`, "",
)
task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 0, 1,
)
require.NoError(t, db.Exec("drop table delivery_results").Error)
e.ExportDeliverHTTP(context.TODO(), db, d, task)
iAssertStatus(t, db, del.ID,
database.DeliveryStatusPending,
)
}
// --- Notify batching --- // --- Notify batching ---
func TestNotify_MultipleTasks(t *testing.T) { func TestNotify_MultipleTasks(t *testing.T) {
+71
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -1056,6 +1057,21 @@ func TestParseHTTPConfig_MissingURL(t *testing.T) {
) )
} }
func TestParseHTTPConfig_Undecodable(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseHTTPConfig(
`{"url":"https://example.com/hook","timeout":"soon"}`,
)
assert.Error(t, err,
"config that does not decode should return error, "+
"even when the part that did names a URL",
)
}
func TestScheduleRetry_SendsToRetryChannel( func TestScheduleRetry_SendsToRetryChannel(
t *testing.T, t *testing.T,
) { ) {
@@ -1241,6 +1257,33 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
) )
} }
// A response that ends before the length it announced is an error, not
// a short body.
func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "100")
_, _ = w.Write([]byte("cut short"))
},
),
)
defer ts.Close()
e := testEngine(t, 1)
_, body, _, err := e.ExportDoHTTPRequest(
context.TODO(),
&delivery.HTTPTargetConfig{URL: ts.URL},
&database.Event{},
)
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
assert.Empty(t, body)
}
// The event's stored inbound headers carry the same Content-Type the // The event's stored inbound headers carry the same Content-Type the
// receiver saved as the event's ContentType, so a delivery could send // receiver saved as the event's ContentType, so a delivery could send
// it twice. It must go out exactly once, with a Content-Type configured // it twice. It must go out exactly once, with a Content-Type configured
@@ -1317,6 +1360,34 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
} }
} }
// Stored inbound headers that do not decode forward nothing, not the
// part of them that happened to decode.
func TestApplyRequestHeaders_UndecodableInboundForwardsNothing(
t *testing.T,
) {
t.Parallel()
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
"https://target.example.com/hook",
http.NoBody,
)
require.NoError(t, err)
names := delivery.ExportApplyRequestHeaders(
req,
&database.Event{
Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`,
},
&delivery.HTTPTargetConfig{},
"webhooker/dev",
)
assert.Empty(t, names)
assert.Empty(t, req.Header.Get("X-Custom"))
}
func TestProcessDelivery_RoutesToCorrectHandler( func TestProcessDelivery_RoutesToCorrectHandler(
t *testing.T, t *testing.T,
) { ) {
@@ -376,3 +376,97 @@ func TestFailedResultWriteLeavesDeliveryRecoverable(
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
} }
// TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable is the same
// rule for a target with retries: whatever the receiver answered, the
// delivery stays pending and no retry is scheduled. The circuit breaker
// still learns the answer, because it describes the target's health,
// not the database's.
func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
t *testing.T,
) {
t.Parallel()
// The "send succeeded" case starts with the breaker tripped open,
// so the delivery goes out as its probe and only a recorded
// success closes it again.
tests := []struct {
name string
answer int
tripped bool
wantBreaker delivery.CircuitState
}{
{"send succeeded", http.StatusOK, true, delivery.CircuitClosed},
{"send failed", http.StatusBadGateway, false, delivery.CircuitOpen},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := newISetup(t)
targetID := uuid.New().String()
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.answer)
},
))
defer ts.Close()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"unwritable":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
require.NoError(
t,
s.WebhookDB.Exec("drop table delivery_results").Error,
)
// A single failure opens this breaker, and with no
// cooldown an open breaker lets the next delivery
// through as a probe.
cb := delivery.NewTestCircuitBreaker(1, 0)
if tc.tripped {
cb.RecordFailure()
}
s.Engine.ExportSetCircuitBreaker(targetID, cb)
full := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "unwritable",
Type: database.TargetTypeHTTP,
Config: iHTTPConfig(ts.URL),
MaxRetries: 3,
},
}
full.ID = d.ID
sched := &recordingScheduler{}
s.Engine.ExportDeliverHTTPWithScheduler(
context.Background(), s.WebhookDB, full,
&delivery.Task{
DeliveryID: d.ID,
TargetID: targetID,
AttemptNum: 1,
},
sched,
)
iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusPending)
assert.Empty(t, sched.delays, "no retry may be scheduled")
assert.Equal(t, tc.wantBreaker, cb.State())
})
}
}
+21
View File
@@ -179,6 +179,27 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) {
) )
} }
// TestDoHTTPRequest_UnparsableURLIsMasked is the same for an HTTP
// target URL that no request can be built from.
func TestDoHTTPRequest_UnparsableURLIsMasked(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
statusCode, _, _, reqErr := e.ExportDoHTTPRequest(
context.TODO(),
&delivery.HTTPTargetConfig{
URL: "https://hooks.example.com" + maskSecretPath + "\n",
},
&database.Event{},
)
require.Error(t, reqErr)
assert.Zero(t, statusCode)
assertNoCredential(t, reqErr.Error())
assert.Contains(t, reqErr.Error(), "invalid control character")
}
// TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF // TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF
// validator's error does not carry the submitted URL, which // validator's error does not carry the submitted URL, which
// the handler both logs and shows. // the handler both logs and shows.
+98 -39
View File
@@ -14,18 +14,16 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// minNonTestFiles guards the walk below against passing because it // isRowProducer reports whether name is GORM's Row or database/sql's
// found nothing to look at. The tree held 60 non-test .go files when // QueryRow or QueryRowContext, which return a *sql.Row whose Scan is
// this was written. // database/sql's and not (*gorm.DB).Scan. GORM's Rows is not listed:
const minNonTestFiles = 40 // it also returns an error, so Scan is never called on its result
// directly. It matches the method name only and resolves no types, so
// isRowProducer reports whether name is a method that returns a // a repo-local method with one of these names that returns *gorm.DB
// database/sql row handle. GORM's Row and Rows return *sql.Row and // gets past it: Scan on that method's result is not reported.
// *sql.Rows, so Scan on the result of one of them is database/sql's
// Scan and never (*gorm.DB).Scan.
func isRowProducer(name string) bool { func isRowProducer(name string) bool {
switch name { switch name {
case "Row", "Rows", "QueryRow", "QueryRowContext": case "Row", "QueryRow", "QueryRowContext":
return true return true
default: default:
return false return false
@@ -50,9 +48,14 @@ func receiverIsRowHandle(x ast.Expr) bool {
} }
// unguardedScans returns the position of every Scan call in file whose // unguardedScans returns the position of every Scan call in file whose
// receiver is not a row handle. It fails closed: a receiver it cannot // receiver is not a call to a row producer. It fails closed: any other
// resolve syntactically — a local variable, a struct field — is // receiver — a local variable, a struct field, a call to any other
// reported rather than assumed safe. // method — is reported rather than assumed safe.
//
// It sees only calls written x.Scan(...). A method value, f := db.Scan
// followed by f(&v), is out of scope: Scan is never the called
// expression there, and nobody writes a query that way by accident,
// which is the mistake this check exists to catch.
func unguardedScans( func unguardedScans(
fset *token.FileSet, file *ast.File, fset *token.FileSet, file *ast.File,
) []token.Position { ) []token.Position {
@@ -111,15 +114,15 @@ func skipDir(name string) bool {
} }
} }
// walkNonTestGo parses every non-test .go file under root and returns // walkNonTestGo parses every non-test .go file under root. It returns
// how many it parsed along with every unguarded Scan it found. // the directories, relative to root, it parsed a file in, along with
func walkNonTestGo(t *testing.T, root string) (int, []string) { // every unguarded Scan it found.
func walkNonTestGo(t *testing.T, root string) (map[string]bool, []string) {
t.Helper() t.Helper()
var ( walked := map[string]bool{}
parsed int
hits []string var hits []string
)
fset := token.NewFileSet() fset := token.NewFileSet()
@@ -147,7 +150,12 @@ func walkNonTestGo(t *testing.T, root string) (int, []string) {
return err return err
} }
parsed++ dir, err := filepath.Rel(root, filepath.Dir(path))
if err != nil {
return err
}
walked[dir] = true
for _, pos := range unguardedScans(fset, file) { for _, pos := range unguardedScans(fset, file) {
hits = append(hits, relPosition(root, pos)) hits = append(hits, relPosition(root, pos))
@@ -157,7 +165,7 @@ func walkNonTestGo(t *testing.T, root string) (int, []string) {
}, },
)) ))
return parsed, hits return walked, hits
} }
// isNonTestGo reports whether a file name is Go source this check // isNonTestGo reports whether a file name is Go source this check
@@ -189,19 +197,39 @@ func relPosition(root string, pos token.Position) string {
// logged with its values interpolated. The package comment states the // logged with its values interpolated. The package comment states the
// limit; this fails when someone adds a call site anyway. // limit; this fails when someone adds a call site anyway.
// //
// The current tree has one caller, internal/database/database_test.go, // Test files are not governed: what a test binds is fixture data.
// which this check does not govern: it is test-only and its SELECT 1
// binds nothing.
func TestGormScanIsNeverCalledOutsideTests(t *testing.T) { func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
t.Parallel() t.Parallel()
parsed, offenders := walkNonTestGo(t, moduleRoot(t)) root := moduleRoot(t)
walked, offenders := walkNonTestGo(t, root)
require.GreaterOrEqual( // The module's packages are static, templates, and every directory
t, parsed, minNonTestFiles, // directly under cmd and internal. Each holds non-test code, so one
"parsed %d non-test .go files, so this check found "+ // the walk parsed nothing in was skipped, and a Scan there would
"nothing to look at", parsed, // pass unseen.
packages := []string{"static", "templates"}
for _, parent := range []string{"cmd", "internal"} {
entries, err := os.ReadDir(filepath.Join(root, parent))
require.NoError(t, err)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
packages = append(packages, filepath.Join(parent, entry.Name()))
}
}
for _, dir := range packages {
require.True(
t, walked[dir],
"the walk parsed no non-test .go file in %s", dir,
) )
}
require.Empty( require.Empty(
t, offenders, t, offenders,
"Scan called on a receiver this check cannot show is a "+ "Scan called on a receiver this check cannot show is a "+
@@ -222,18 +250,51 @@ type scanGuardCase struct {
want int want int
} }
// scanGuardCases covers each receiver form unguardedScans names, plus
// each row producer isRowProducer lets through. Each body is valid Go
// inside plantedFile.
func scanGuardCases() []scanGuardCase { func scanGuardCases() []scanGuardCase {
return []scanGuardCase{ return []scanGuardCase{
{"gorm chain", `db.DB().Raw("SELECT 1").Scan(&v)`, 1}, {"local variable", "q := gdb.Raw(\"SELECT 1\")\n\tq.Scan(&v)", 1},
{"gorm receiver", `gdb.Scan(&v)`, 1}, {"struct field", `s.db.Scan(&v)`, 1},
{"gorm via variable", "q := gdb.Raw(\"x\")\nq.Scan(&v)", 1}, {"gorm chain", `gdb.Raw("SELECT 1").Scan(&v)`, 1},
{"gorm model chain", `gdb.Model(&x).Scan(&v)`, 1}, {
{"sql row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0}, "sql rows in a variable",
{"sql rows", `gdb.Raw("SELECT 1").Rows().Scan(&v)`, 0}, "rows, _ := gdb.Raw(\"SELECT 1\").Rows()\n\trows.Scan(&v)",
1,
},
{"gorm Row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
{"sql QueryRow", `sqlDB.QueryRow("SELECT 1").Scan(&v)`, 0},
{
"sql QueryRowContext",
`sqlDB.QueryRowContext(ctx, "SELECT 1").Scan(&v)`,
0,
},
{"unrelated call", `gdb.Find(&v)`, 0}, {"unrelated call", `gdb.Find(&v)`, 0},
} }
} }
// plantedFile wraps one case body in a function that declares every
// name the bodies use, so each body is the Go it stands for. The result
// is parsed, never compiled.
const plantedFile = `package p
import (
"context"
"database/sql"
"gorm.io/gorm"
)
type store struct{ db *gorm.DB }
func f(ctx context.Context, gdb *gorm.DB, sqlDB *sql.DB, s store) {
var v int
%s
}
`
// TestScanGuard_ReportsPlantedCalls proves the check fires. Without it // TestScanGuard_ReportsPlantedCalls proves the check fires. Without it
// a detector that matched nothing would satisfy the walk above no // a detector that matched nothing would satisfy the walk above no
// matter what the tree contained. // matter what the tree contained.
@@ -245,9 +306,7 @@ func TestScanGuard_ReportsPlantedCalls(t *testing.T) {
t.Parallel() t.Parallel()
fset := token.NewFileSet() fset := token.NewFileSet()
src := fmt.Sprintf( src := fmt.Sprintf(plantedFile, tc.body)
"package p\n\nfunc f() {\n\t%s\n}\n", tc.body,
)
file, err := parser.ParseFile( file, err := parser.ParseFile(
fset, tc.name+".go", src, 0, fset, tc.name+".go", src, 0,
+6 -4
View File
@@ -15,10 +15,12 @@ import (
// eventBodyQuery reads one event's stored body as bytes. The cast // eventBodyQuery reads one event's stored body as bytes. The cast
// to blob is what makes the driver hand back the stored bytes // to blob is what makes the driver hand back the stored bytes
// rather than a string conversion, so Content-Length taken from // rather than a string conversion, so Content-Length taken from
// the result matches what goes on the wire. The soft-delete // the result matches what goes on the wire. The retention reaper
// predicate is spelled out because Raw bypasses GORM's default // deletes event rows outright, so a reaped event is simply gone
// scope, and it is what stops a reaped event still being // and the query finds no row. The deleted_at predicate repeats
// downloadable. // the soft-delete scope GORM adds to its own queries, which Raw
// bypasses; nothing soft-deletes an event, so today it excludes
// nothing.
const eventBodyQuery = "SELECT cast(body as blob) " + const eventBodyQuery = "SELECT cast(body as blob) " +
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL" "FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
+5 -4
View File
@@ -405,10 +405,11 @@ func TestHandleEventBodyDownload_UnknownEvent404s(t *testing.T) {
// route. The body is read in one query before any header is // route. The body is read in one query before any header is
// written, so a reaped event cannot produce a partial download: // written, so a reaped event cannot produce a partial download:
// it is a clean 404 with no Content-Length and no // it is a clean 404 with no Content-Length and no
// Content-Disposition. Both removals the codebase performs are // Content-Disposition. The reaper deletes event rows outright,
// covered — the reaper hard-deletes, and a soft-deleted row is // which is the "hard deleted" case. The "soft deleted" case
// excluded by the query's own deleted_at predicate rather than // covers a row no code produces today: it only pins the query's
// by GORM's default scope, which Raw bypasses. // own deleted_at predicate, the soft-delete condition Raw would
// otherwise skip.
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) { func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
t.Parallel() t.Parallel()
+3 -2
View File
@@ -145,8 +145,9 @@ func (h *Handlers) resubmitEvent(
// per-webhook database files — a sibling webhook's event is not in the // per-webhook database files — a sibling webhook's event is not in the
// database being queried at all — and is there so the scoping survives // database being queried at all — and is there so the scoping survives
// any future change that puts more than one webhook's events in one // any future change that puts more than one webhook's events in one
// file. Going through Model applies GORM's soft-delete scope, which is // file. A reaped event is not found because the retention reaper
// what stops a reaped event being resubmitted. // deletes its row outright rather than marking it deleted; see
// deleteEvents in internal/database/retention.go.
func loadResubmitSource( func loadResubmitSource(
webhookDB *gorm.DB, webhookDB *gorm.DB,
webhookID, eventID string, webhookID, eventID string,
+9
View File
@@ -36,6 +36,15 @@ const MaxRenderedAttemptsForTest = maxRenderedAttempts
// the handlers enforce rather than a number copied beside it. // the handlers enforce rather than a number copied beside it.
const MaxTargetRetriesForTest = maxTargetRetries const MaxTargetRetriesForTest = maxTargetRetries
// EventDBLeftMsgForTest and SidecarLeftMsgForTest expose the two
// messages the webhook delete handler logs when a file of the event
// database is left on disk, so a test checking that one is absent
// checks for the handler's own wording.
const (
EventDBLeftMsgForTest = eventDBLeftMsg
SidecarLeftMsgForTest = sidecarLeftMsg
)
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test // PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
// package. // package.
func PageOrFirstForTest(s string) int { func PageOrFirstForTest(s string) int {
+117
View File
@@ -1,8 +1,10 @@
package handlers_test package handlers_test
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -466,6 +468,121 @@ func TestHandleSourceDelete_RemovesConfigAndEventDatabase(
) )
} }
// TestHandleSourceDelete_LeftoverSidecar proves that when the event
// database file is removed but a sidecar beside it is not, the
// operator is told the events are gone, never that the event
// database file is still there.
func TestHandleSourceDelete_LeftoverSidecar(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
mgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &mgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
logs := new(bytes.Buffer)
h.SetLogForTest(slog.New(slog.NewTextHandler(logs, nil)))
wh := seedWebhook(t, db)
require.NoError(t, mgr.CreateDB(wh.ID))
// Closing removes the sidecars, so the -wal below is the only
// one there.
require.NoError(t, mgr.CloseAll())
// A non-empty directory in the -wal file's place, which
// os.Remove cannot remove whoever runs the test.
eventDBPath := mgr.DBPath(wh.ID)
require.NoError(t, os.MkdirAll(
filepath.Join(eventDBPath+"-wal", "keep"), 0o700,
))
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
w := httptest.NewRecorder()
h.HandleSourceDelete().ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.NoFileExists(t, eventDBPath)
assert.Contains(t, logs.String(), "its events are gone")
assert.Contains(t, logs.String(), eventDBPath+"-wal")
assert.NotContains(
t, logs.String(), handlers.EventDBLeftMsgForTest,
"the events are gone, so the operator must not be told "+
"the event database file survived",
)
}
// TestHandleSourceDelete_LeftoverDatabaseFile proves that when the
// event database file itself cannot be removed, the operator is told
// it is still on disk, never that its events are gone.
func TestHandleSourceDelete_LeftoverDatabaseFile(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
mgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &mgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
logs := new(bytes.Buffer)
h.SetLogForTest(slog.New(slog.NewTextHandler(logs, nil)))
wh := seedWebhook(t, db)
// A non-empty directory in the database file's place, which
// os.Remove cannot remove whoever runs the test.
eventDBPath := mgr.DBPath(wh.ID)
require.NoError(t, os.MkdirAll(
filepath.Join(eventDBPath, "keep"), 0o700,
))
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
req := postRequest(
"/hook/"+wh.ID+"/delete",
cookies,
map[string]string{paramSourceID: wh.ID},
)
w := httptest.NewRecorder()
h.HandleSourceDelete().ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(
t, logs.String(), "event database file is still on disk",
)
assert.Contains(t, logs.String(), eventDBPath)
assert.NotContains(
t, logs.String(), handlers.SidecarLeftMsgForTest,
"the database file is still on disk, so the operator must "+
"not be told its events are gone",
)
}
// TestHandleTargetDelete_EvictsThatTarget proves that deleting a // TestHandleTargetDelete_EvictsThatTarget proves that deleting a
// database target releases that target's archive writer and no // database target releases that target's archive writer and no
// other: the webhook's other database target keeps its own. // other: the webhook's other database target keeps its own.
+20 -4
View File
@@ -723,6 +723,17 @@ func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
} }
} }
// The messages deleteWebhookResources logs when a file of the event
// database cannot be removed: the database file itself, or only a
// sidecar once the database file is gone.
const (
eventDBLeftMsg = "webhook deleted, but its event database file is " +
"still on disk; remove it by hand"
sidecarLeftMsg = "webhook deleted and its events are gone, but a " +
"-wal or -shm sidecar of its event database is " +
"still on disk; remove it by hand"
)
// deleteWebhookResources soft-deletes config and hard-deletes // deleteWebhookResources soft-deletes config and hard-deletes
// the per-webhook event database. // the per-webhook event database.
func (h *Handlers) deleteWebhookResources( func (h *Handlers) deleteWebhookResources(
@@ -762,13 +773,18 @@ func (h *Handlers) deleteWebhookResources(
err = h.dbMgr.DeleteDB(webhook.ID) err = h.dbMgr.DeleteDB(webhook.ID)
if err != nil { if err != nil {
// The configuration is committed, so the webhook is gone, // The configuration is committed, so the webhook is gone,
// but its event database file is still on disk with // but a file of its event database is still on disk with
// nothing referencing it. Report the failure rather than // nothing referencing it. Report the failure rather than
// redirecting as though everything succeeded: the file // redirecting as though everything succeeded: the file
// needs removing by hand, and the logged error names it. // needs removing by hand, and the logged error names it.
h.serverError( // When only a sidecar is left, the events are already
w, r, "failed to delete webhook event database", err, // gone, and the message must not suggest they survive.
) msg := eventDBLeftMsg
if errors.Is(err, database.ErrSidecarNotRemoved) {
msg = sidecarLeftMsg
}
h.serverError(w, r, msg, err)
return return
} }
+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
+6 -4
View File
@@ -272,10 +272,12 @@ func requestEventSource(
// createAndFanOut writes the event and one pending delivery per target, // createAndFanOut writes the event and one pending delivery per target,
// and adds them to the webhook's running totals, in a single // and adds them to the webhook's running totals, in a single
// transaction, then hands the tasks to the delivery engine. It is the // transaction, then hands the tasks to the delivery engine. Every
// only path by which an event and its deliveries are created, so a // event is created here, received or resubmitted, so a resubmitted
// resubmitted event is retried, SSRF-guarded and circuit-broken // event is retried, SSRF-guarded and circuit-broken exactly as a
// exactly as a received one is. // received one is. Per-delivery replay is the one other path that
// creates a delivery: it adds one to an existing event without
// coming through here.
// //
// The tasks are returned as well as queued, so a caller can report how // The tasks are returned as well as queued, so a caller can report how
// many targets the event went to. // many targets the event went to.
+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
+10
View File
@@ -1,6 +1,8 @@
package server package server
import ( import (
"context"
"log/slog"
"net/http" "net/http"
"testing" "testing"
@@ -37,6 +39,14 @@ func SentryClientOptionsForTest(
return sentryClientOptions(dsn, release) return sentryClientOptions(dsn, release)
} }
// CleanShutdownForTest runs the server's stop hook, cleanShutdown,
// against hs: a server the test started itself, so it can hold a
// request open across the drain. Sentry is off.
func CleanShutdownForTest(ctx context.Context, hs *http.Server) {
s := &Server{log: slog.New(slog.DiscardHandler), httpServer: hs}
s.cleanShutdown(ctx)
}
// newServerForTest builds a Server through New, as the application // newServerForTest builds a Server through New, as the application
// does, on a lifecycle that is never started: the hooks New adds to // does, on a lifecycle that is never started: the hooks New adds to
// it never run, so nothing listens. // it never run, so nothing listens.
+215
View File
@@ -0,0 +1,215 @@
package server_test
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// maxResubmits bounds the requests the tests below send to the
// resubmit route. The route's rate limit belongs to the middleware;
// this only has to sit well above it, so that a route without the
// limiter fails its test instead of looping.
const maxResubmits = 100
// resubmitPath is the resubmit route for one stored event.
func resubmitPath(webhookID, eventID string) string {
return "/hook/" + webhookID + "/events/" + eventID + "/resubmit"
}
// csrfForm is a resubmit form carrying the given CSRF token.
func csrfForm(token string) url.Values {
form := url.Values{}
form.Set("csrf_token", token)
return form
}
// TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit pins
// RequireAuth on the resubmit route. The handler also turns away a
// request without a session, with the same redirect, so a refusal
// alone would pass without RequireAuth. What RequireAuth adds is that
// it refuses such a request before the route's rate limit, so a
// signed-out client cannot spend the budget a signed-in user
// resubmits from. Each request carries a CSRF token valid for its own
// cookie, so CSRF lets it through to RequireAuth.
func TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit(
t *testing.T,
) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
wh := env.seedWebhook(t, userID)
evt := env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
path := resubmitPath(wh.ID, evt.ID)
logsPath := "/hook/" + wh.ID + "/events"
token, signedOut := env.csrfFrom(t, "/pages/login", nil)
for i := range maxResubmits {
w := env.post(path, csrfForm(token), signedOut)
require.Equal(t, http.StatusSeeOther, w.Code, "request %d", i)
require.Equal(
t, "/pages/login", w.Header().Get("Location"),
"request %d", i,
)
}
require.Equal(
t, int64(1), env.countEvents(t, wh.ID),
"a signed-out request must store nothing",
)
token, cookies := env.csrfFrom(
t, logsPath, env.authCookies(t, userID, "resubmitter"),
)
env.requireNotice(
t, env.post(path, csrfForm(token), cookies),
logsPath, "resubmit-no-targets",
"this source has no active targets", cookies,
)
}
// TestEventResubmit_RefusedWithoutAValidCSRFToken pins CSRF on the
// resubmit route: a signed-in user's POST is refused with 403, and
// stores nothing, unless it carries the token issued to that user's
// own browser.
func TestEventResubmit_RefusedWithoutAValidCSRFToken(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
wh := env.seedWebhook(t, userID)
evt := env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
path := resubmitPath(wh.ID, evt.ID)
logsPath := "/hook/" + wh.ID + "/events"
token, cookies := env.csrfFrom(
t, logsPath, env.authCookies(t, userID, "resubmitter"),
)
otherBrowsers, _ := env.csrfFrom(t, "/pages/login", nil)
for name, form := range map[string]url.Values{
"no token": {},
"a malformed token": csrfForm("not-a-token"),
"another browser's token": csrfForm(otherBrowsers),
} {
assert.Equal(
t, http.StatusForbidden,
env.post(path, form, cookies).Code, name,
)
}
assert.Equal(
t, int64(1), env.countEvents(t, wh.ID),
"a refused request must store nothing",
)
// The same request with the user's own token goes through, so the
// refusals above were the token's doing.
env.requireNotice(
t, env.post(path, csrfForm(token), cookies),
logsPath, "resubmit-no-targets",
"this source has no active targets", cookies,
)
}
// TestEventResubmit_AnotherWebhooksEvent404s pins, on the route as
// registered, that a signed-in user gets 404, and nothing is stored,
// for an event of a webhook another user owns, which the handler's
// ownership check refuses, and for another webhook's event posted
// under a webhook the user does own, which the event lookup refuses.
// The user's own event, posted the same way, is accepted, so the
// second 404 comes from the lookup and not from a route that never
// passed the event ID to the handler.
func TestEventResubmit_AnotherWebhooksEvent404s(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ownerID, _ := env.seedUser(t, "owner", "somepassword")
owners := env.seedWebhook(t, ownerID)
ownersEvent := env.seedEvent(t, owners.ID, `{"owner":"only"}`)
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
intruders := env.seedWebhook(t, intruderID)
intrudersEvent := env.seedEvent(
t, intruders.ID, `{"intruder":"own"}`,
)
intrudersLogs := "/hook/" + intruders.ID + "/events"
token, cookies := env.csrfFrom(
t, intrudersLogs, env.authCookies(t, intruderID, "intruder"),
)
for name, path := range map[string]string{
"another user's webhook": resubmitPath(
owners.ID, ownersEvent.ID,
),
"another webhook's event": resubmitPath(
intruders.ID, ownersEvent.ID,
),
} {
w := env.post(path, csrfForm(token), cookies)
assert.Equal(t, http.StatusNotFound, w.Code, name)
}
assert.Equal(t, int64(1), env.countEvents(t, owners.ID))
assert.Equal(t, int64(1), env.countEvents(t, intruders.ID))
env.requireNotice(
t,
env.post(
resubmitPath(intruders.ID, intrudersEvent.ID),
csrfForm(token), cookies,
),
intrudersLogs, "resubmit-no-targets",
"this source has no active targets", cookies,
)
}
// TestEventResubmit_RateLimited pins the rate limit on the resubmit
// route: a signed-in user's resubmits are accepted until the budget
// is spent, and then refused with 429.
func TestEventResubmit_RateLimited(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
userID, _ := env.seedUser(t, "resubmitter", "somepassword")
wh := env.seedWebhook(t, userID)
evt := env.seedEvent(t, wh.ID, `{"resubmit":"me"}`)
path := resubmitPath(wh.ID, evt.ID)
token, cookies := env.csrfFrom(
t, "/hook/"+wh.ID+"/events",
env.authCookies(t, userID, "resubmitter"),
)
limited := false
for range maxResubmits {
code := env.post(path, csrfForm(token), cookies).Code
if code == http.StatusTooManyRequests {
limited = true
break
}
require.Equal(
t, http.StatusSeeOther, code,
"a resubmit within the budget must be accepted",
)
}
assert.True(
t, limited, "repeated resubmits must eventually be refused",
)
}
+19 -2
View File
@@ -444,6 +444,23 @@ func (e *testEnv) countDeliveries(
return count return count
} }
// countEvents reports how many events a webhook's database holds.
func (e *testEnv) countEvents(t *testing.T, webhookID string) int64 {
t.Helper()
webhookDB, err := e.dbMgr.GetDB(webhookID)
require.NoError(t, err)
var count int64
require.NoError(
t,
webhookDB.Model(&database.Event{}).Count(&count).Error,
)
return count
}
// storedHash reads the current password hash for a username. // storedHash reads the current password hash for a username.
func (e *testEnv) storedHash(t *testing.T, username string) string { func (e *testEnv) storedHash(t *testing.T, username string) string {
t.Helper() t.Helper()
@@ -1220,12 +1237,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},
+26 -3
View File
@@ -39,6 +39,12 @@ const (
// refuses to spend, leaving it for the hooks that run after the // refuses to spend, leaving it for the hooks that run after the
// server: the delivery engine, the healthcheck, the webhook DB // server: the delivery engine, the healthcheck, the webhook DB
// manager and the database close. // manager and the database close.
//
// Its value is not tuned to those hooks, which take about a
// millisecond between them. It is what the 5s fx stop timeout in
// cmd/webhooker leaves after a full ShutdownTimeout drain, so a
// drain that starts on a full budget still gets all of
// ShutdownTimeout.
TailHookReserve = 2 * time.Second TailHookReserve = 2 * time.Second
// sentryFlushTimeout is the longest wait for Sentry to flush // sentryFlushTimeout is the longest wait for Sentry to flush
@@ -59,6 +65,16 @@ const (
// key off it, and a zero exit would read as a deliberate stop. // key off it, and a zero exit would read as a deliberate stop.
const StartupFailureExitCode = 1 const StartupFailureExitCode = 1
// DrainBudget reports how long the HTTP drain may wait for in-flight
// requests when remaining is the time left on the fx stop context as
// the server's stop hook starts. The hooks before the server can
// already have spent part of the budget, so the drain takes its time
// out of what they left, never out of TailHookReserve. Zero or less
// means no wait at all.
func DrainBudget(remaining time.Duration) time.Duration {
return min(ShutdownTimeout, remaining-TailHookReserve)
}
// SentryFlushBudget reports how long the Sentry flush may run when // SentryFlushBudget reports how long the Sentry flush may run when
// remaining is the time left on the fx stop context after the HTTP // remaining is the time left on the fx stop context after the HTTP
// drain. sentry.Flush takes a bare duration and honours no context, // drain. sentry.Flush takes a bare duration and honours no context,
@@ -261,10 +277,17 @@ func (s *Server) cleanupForExit() {
s.log.Info("cleaning up") s.log.Info("cleaning up")
} }
// cleanShutdown drains the HTTP server and flushes Sentry inside what
// is left of the fx stop budget. A context carrying no deadline — a
// caller outside the fx lifecycle — gets the full ShutdownTimeout.
func (s *Server) cleanShutdown(ctx context.Context) { func (s *Server) cleanShutdown(ctx context.Context) {
ctxShutdown, shutdownCancel := context.WithTimeout( drain := ShutdownTimeout
ctx, ShutdownTimeout,
) if deadline, ok := ctx.Deadline(); ok {
drain = DrainBudget(time.Until(deadline))
}
ctxShutdown, shutdownCancel := context.WithTimeout(ctx, drain)
defer shutdownCancel() defer shutdownCancel()
err := s.httpServer.Shutdown(ctxShutdown) err := s.httpServer.Shutdown(ctxShutdown)
+135
View File
@@ -1,13 +1,148 @@
package server_test package server_test
import ( import (
"context"
"net"
"net/http"
"testing" "testing"
"testing/synctest"
"time" "time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/server" "sneak.berlin/go/webhooker/internal/server"
) )
// TestDrainBudget covers the clamp that keeps the HTTP drain from
// spending the tail hooks' share of the fx stop budget when the hooks
// before the server have already used part of it.
func TestDrainBudget(t *testing.T) {
t.Parallel()
tests := []struct {
name string
remaining time.Duration
want time.Duration
}{
{
name: "only the reserve is left",
remaining: server.TailHookReserve,
want: 0,
},
{
name: "earlier hooks spent part of the budget",
remaining: server.TailHookReserve + time.Second,
want: time.Second,
},
{
name: "capped at the nominal timeout",
remaining: time.Hour,
want: server.ShutdownTimeout,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, server.DrainBudget(tt.remaining))
})
}
}
// TestCleanShutdown_LeavesTailHookReserve stops the server with a
// request still in flight, after the hooks before it have spent all
// of the stop budget but TailHookReserve. The drain must give up at
// once rather than wait for the request: what is left belongs to the
// hooks after the server, the database close among them. A drain
// bounded only by ShutdownTimeout waits until the stop context
// expires, and fx then skips those hooks.
//
// The test runs in a synctest bubble, whose clock moves only while
// every goroutine in it is blocked, so a drain that gives up at once
// leaves the stop context unexpired however slow the host is. The
// request travels over net.Pipe because a goroutine waiting on a
// real socket would stop that clock from moving at all.
func TestCleanShutdown_LeavesTailHookReserve(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
entered := make(chan struct{})
release := make(chan struct{})
hs := &http.Server{
Handler: http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {
close(entered)
<-release
},
),
ReadHeaderTimeout: time.Second,
}
srvConn, cliConn := net.Pipe()
listener := pipeListener{
conns: make(chan net.Conn, 1),
closed: make(chan struct{}),
}
listener.conns <- srvConn
go func() { _ = hs.Serve(listener) }()
// Cleanups run last first: the handler returns, then closing
// the client end ends the server's write of the response.
t.Cleanup(func() { _ = cliConn.Close() })
t.Cleanup(func() { close(release) })
_, err := cliConn.Write(
[]byte("GET / HTTP/1.1\r\nHost: webhooker.test\r\n\r\n"),
)
require.NoError(t, err)
<-entered
stopCtx, cancel := context.WithTimeout(
t.Context(), server.TailHookReserve,
)
defer cancel()
server.CleanShutdownForTest(stopCtx, hs)
require.NoError(
t, stopCtx.Err(), "the drain spent the tail hooks' reserve",
)
})
}
// pipeListener is the net.Listener http.Server.Serve needs to serve
// the server end of a net.Pipe: Accept returns that one connection,
// then waits until Close, as a real listener with no more clients
// does.
type pipeListener struct {
conns chan net.Conn
closed chan struct{}
}
func (l pipeListener) Accept() (net.Conn, error) {
select {
case conn := <-l.conns:
return conn, nil
case <-l.closed:
return nil, net.ErrClosed
}
}
func (l pipeListener) Close() error {
close(l.closed)
return nil
}
// Addr is never called by http.Server.Serve.
func (pipeListener) Addr() net.Addr {
return nil
}
// TestSentryFlushBudget covers the clamp that keeps the Sentry flush // TestSentryFlushBudget covers the clamp that keeps the Sentry flush
// from spending the tail hooks' share of the fx stop budget. // from spending the tail hooks' share of the fx stop budget.
// sentry.Flush ignores the stop context, so without the clamp a // sentry.Flush ignores the stop context, so without the clamp a
+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>