Compare commits
8
Commits
6aa9907c8e
...
9b44189a91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b44189a91 | ||
|
|
f82b730c31 | ||
|
|
1a1fee0874 | ||
|
|
290925f184 | ||
|
|
73353bc8e5 | ||
|
|
40f59ec4d2 | ||
|
|
806c95e305 | ||
|
|
45bd7e9b94 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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}}
|
||||||
|
|||||||
@@ -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">← Back to webhooks</a>
|
<a href="/hooks" class="btn-small">← 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}}
|
||||||
|
|||||||
@@ -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">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← 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
@@ -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">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← 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 — <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 — <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">
|
||||||
|
|||||||
@@ -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 →</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>
|
||||||
|
|||||||
@@ -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">← Back to webhooks</a>
|
<a href="/hooks" class="btn-small">← 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user