check / check (push) Successful in 3m21s
While an http or slack target's circuit breaker turns its deliveries away, the target's row on the webhook page says its deliveries are paused and until when, in UTC and from now. Each of its retrying deliveries shows as waiting, with the reason and the same time, in the event log and on the event's page, instead of a bare "retrying". The engine gains one read, CooldownRemaining(targetID), over the breakers it already keeps; the handlers reach it through a one-method interface wired like Archives. Model: opus-5-5
522 lines
12 KiB
Go
522 lines
12 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/fx"
|
|
"go.uber.org/fx/fxtest"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
"sneak.berlin/go/webhooker/internal/globals"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/healthcheck"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
"sneak.berlin/go/webhooker/internal/metrics"
|
|
"sneak.berlin/go/webhooker/internal/middleware"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
// recordingNotifier is a delivery.Notifier that records the tasks it
|
|
// was handed, so a test can prove a handler queued the delivery it
|
|
// claims to have queued — and, on the refusal paths, that it queued
|
|
// nothing.
|
|
type recordingNotifier struct {
|
|
mu sync.Mutex
|
|
tasks []delivery.Task
|
|
}
|
|
|
|
func (n *recordingNotifier) Notify(tasks []delivery.Task) {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
|
|
n.tasks = append(n.tasks, tasks...)
|
|
}
|
|
|
|
// Tasks returns a copy of the recorded tasks.
|
|
func (n *recordingNotifier) Tasks() []delivery.Task {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
|
|
out := make([]delivery.Task, len(n.tasks))
|
|
copy(out, n.tasks)
|
|
|
|
return out
|
|
}
|
|
|
|
// recordingArchives is a delivery.Archives that records what it
|
|
// was asked to do, so a test can prove that a deletion or rename
|
|
// path reached the delivery engine. After FailRenames, every
|
|
// rename of that target fails with the given error. After
|
|
// BlockNextRename, the next rename is recorded and then waits.
|
|
type recordingArchives struct {
|
|
mu sync.Mutex
|
|
evicted []string
|
|
evictedTargets []string
|
|
renames []archiveRename
|
|
renameErrs map[string]error
|
|
entered chan struct{}
|
|
release chan struct{}
|
|
}
|
|
|
|
// errInjectedRename is the failure a test hands FailRenames.
|
|
var errInjectedRename = errors.New("injected rename failure")
|
|
|
|
// errNameTaken is what the delivery engine returns when a file
|
|
// already has an archive's new name, here archive-taken.db.
|
|
var errNameTaken = fmt.Errorf(
|
|
"%w: archive-taken.db", delivery.ErrArchiveNameTaken,
|
|
)
|
|
|
|
// archiveRename is one recorded Rename call.
|
|
type archiveRename struct {
|
|
TargetID string
|
|
WebhookName string
|
|
TargetName string
|
|
}
|
|
|
|
func (r *recordingArchives) EvictWebhook(webhookID string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.evicted = append(r.evicted, webhookID)
|
|
}
|
|
|
|
func (r *recordingArchives) EvictTarget(targetID string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.evictedTargets = append(r.evictedTargets, targetID)
|
|
}
|
|
|
|
func (r *recordingArchives) Rename(
|
|
targetID, webhookName, targetName string,
|
|
) error {
|
|
r.mu.Lock()
|
|
|
|
r.renames = append(r.renames, archiveRename{
|
|
TargetID: targetID,
|
|
WebhookName: webhookName,
|
|
TargetName: targetName,
|
|
})
|
|
err := r.renameErrs[targetID]
|
|
entered, release := r.entered, r.release
|
|
r.entered, r.release = nil, nil
|
|
|
|
r.mu.Unlock()
|
|
|
|
if entered != nil {
|
|
close(entered)
|
|
<-release
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// BlockNextRename makes the next rename, once recorded, wait until
|
|
// the returned release is called. The returned channel is closed
|
|
// when that rename starts waiting.
|
|
func (r *recordingArchives) BlockNextRename() (<-chan struct{}, func()) {
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
|
|
r.mu.Lock()
|
|
r.entered, r.release = entered, release
|
|
r.mu.Unlock()
|
|
|
|
return entered, func() { close(release) }
|
|
}
|
|
|
|
// FailRenames makes every later rename of targetID fail with err.
|
|
func (r *recordingArchives) FailRenames(targetID string, err error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
if r.renameErrs == nil {
|
|
r.renameErrs = map[string]error{}
|
|
}
|
|
|
|
r.renameErrs[targetID] = err
|
|
}
|
|
|
|
// Evicted returns a copy of the recorded webhook ids.
|
|
func (r *recordingArchives) Evicted() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
out := make([]string, len(r.evicted))
|
|
copy(out, r.evicted)
|
|
|
|
return out
|
|
}
|
|
|
|
// EvictedTargets returns a copy of the recorded target ids.
|
|
func (r *recordingArchives) EvictedTargets() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
out := make([]string, len(r.evictedTargets))
|
|
copy(out, r.evictedTargets)
|
|
|
|
return out
|
|
}
|
|
|
|
// Renames returns a copy of the recorded renames.
|
|
func (r *recordingArchives) Renames() []archiveRename {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
out := make([]archiveRename, len(r.renames))
|
|
copy(out, r.renames)
|
|
|
|
return out
|
|
}
|
|
|
|
// testCircuitBreakers is a delivery.CircuitBreakers over real circuit
|
|
// breakers, one per target a test asks for, so a test can trip a
|
|
// target's breaker with RecordFailure and close it with RecordSuccess.
|
|
type testCircuitBreakers struct {
|
|
mu sync.Mutex
|
|
breakers map[string]*delivery.CircuitBreaker
|
|
}
|
|
|
|
// Breaker returns the target's circuit breaker, making it on first
|
|
// use.
|
|
func (b *testCircuitBreakers) Breaker(
|
|
targetID string,
|
|
) *delivery.CircuitBreaker {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if b.breakers == nil {
|
|
b.breakers = map[string]*delivery.CircuitBreaker{}
|
|
}
|
|
|
|
if b.breakers[targetID] == nil {
|
|
b.breakers[targetID] = delivery.NewCircuitBreaker()
|
|
}
|
|
|
|
return b.breakers[targetID]
|
|
}
|
|
|
|
func (b *testCircuitBreakers) CooldownRemaining(
|
|
targetID string,
|
|
) time.Duration {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
cb := b.breakers[targetID]
|
|
if cb == nil {
|
|
return 0
|
|
}
|
|
|
|
return cb.CooldownRemaining()
|
|
}
|
|
|
|
// newTestApp returns an app whose RequireStart fails the test when
|
|
// starting takes longer than fx's default start timeout of 15s. That
|
|
// limit catches a start that hangs, not a busy host: measured with make
|
|
// test on 2026-10-02 at host load 58-69 on 48 cores, the slowest of this
|
|
// package's starts took 0.49s.
|
|
func newTestApp(
|
|
t *testing.T,
|
|
targets ...any,
|
|
) *fxtest.App {
|
|
t.Helper()
|
|
|
|
return newTestAppWithConfig(
|
|
t, &config.Config{DataDir: t.TempDir()}, targets...,
|
|
)
|
|
}
|
|
|
|
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
|
|
func newTestAppWithConfig(
|
|
t *testing.T,
|
|
cfg *config.Config,
|
|
targets ...any,
|
|
) *fxtest.App {
|
|
t.Helper()
|
|
|
|
return fxtest.New(
|
|
t,
|
|
// fx's own log is discarded, not sent to t.Logf: a hook still
|
|
// running after a start or stop timeout would write there after
|
|
// the test has returned.
|
|
fx.NopLogger,
|
|
fx.Provide(
|
|
globals.New,
|
|
logger.New,
|
|
func() *config.Config { return cfg },
|
|
database.New,
|
|
database.NewWebhookDBManager,
|
|
healthcheck.New,
|
|
session.New,
|
|
func() *recordingNotifier {
|
|
return &recordingNotifier{}
|
|
},
|
|
func(n *recordingNotifier) delivery.Notifier {
|
|
return n
|
|
},
|
|
func() *recordingArchives {
|
|
return &recordingArchives{}
|
|
},
|
|
func(r *recordingArchives) delivery.Archives {
|
|
return r
|
|
},
|
|
func() *testCircuitBreakers {
|
|
return &testCircuitBreakers{}
|
|
},
|
|
func(b *testCircuitBreakers) delivery.CircuitBreakers {
|
|
return b
|
|
},
|
|
metrics.NewRegistry,
|
|
metrics.New,
|
|
middleware.New,
|
|
delivery.NewGuard,
|
|
handlers.New,
|
|
),
|
|
fx.Populate(targets...),
|
|
)
|
|
}
|
|
|
|
func TestHandleIndex_Unauthenticated(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, &h)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler := h.HandleIndex()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
|
assert.Equal(
|
|
t, "/pages/login", w.Header().Get("Location"),
|
|
)
|
|
}
|
|
|
|
func TestHandleIndex_Authenticated(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
var sess *session.Session
|
|
|
|
app := newTestApp(t, &h, &sess)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
s, err := sess.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
sess.SetUser(s, "test-user-id", "testuser")
|
|
|
|
err = sess.Save(req, w, s)
|
|
require.NoError(t, err)
|
|
|
|
req2 := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
|
|
for _, cookie := range w.Result().Cookies() {
|
|
req2.AddCookie(cookie)
|
|
}
|
|
|
|
w2 := httptest.NewRecorder()
|
|
h.HandleIndex().ServeHTTP(w2, req2)
|
|
|
|
assert.Equal(t, http.StatusSeeOther, w2.Code)
|
|
assert.Equal(
|
|
t, "/hooks", w2.Header().Get("Location"),
|
|
)
|
|
}
|
|
|
|
func TestBuildSlackTargetConfig_AcceptsPublicURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, &h)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
|
|
t.Context(), "http://93.184.216.34/services/T00/B00/xxx",
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
assert.Empty(t, errMsg)
|
|
assert.Contains(t, cfg, "webhookUrl")
|
|
}
|
|
|
|
func TestBuildSlackTargetConfig_RejectsReservedURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, &h)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
cfg, errMsg, err := h.BuildSlackTargetConfigForTest(
|
|
t.Context(), "http://169.254.169.254/latest/meta-data/",
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
assert.Contains(t, errMsg, "Invalid target URL")
|
|
assert.Empty(t, cfg)
|
|
}
|
|
|
|
func TestRenderTemplate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, &h)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
data := map[string]any{"Version": "1.0.0"}
|
|
|
|
h.RenderTemplateForTest(
|
|
w, req, "nonexistent.html", data,
|
|
)
|
|
|
|
assert.Equal(
|
|
t, http.StatusInternalServerError, w.Code,
|
|
)
|
|
}
|
|
|
|
// errMidRender is the failure a test template raises partway through
|
|
// rendering.
|
|
var errMidRender = errors.New("deliberate mid-render failure")
|
|
|
|
// midRenderFailure is template data whose first method renders and
|
|
// whose second fails, so the template aborts after output has
|
|
// already been produced.
|
|
type midRenderFailure struct{}
|
|
|
|
// Prefix is the output a streaming renderer would flush before the
|
|
// failure below aborts the template.
|
|
func (midRenderFailure) Prefix() string { return partialPageMarker }
|
|
|
|
// Boom aborts template execution.
|
|
func (midRenderFailure) Boom() (string, error) {
|
|
return "", errMidRender
|
|
}
|
|
|
|
// partialPageMarker is content the failing template emits before it
|
|
// aborts.
|
|
const partialPageMarker = "PARTIAL PAGE CONTENT"
|
|
|
|
// TestRenderTemplateMidRenderErrorSendsNoPartialBody proves the
|
|
// renderer does not commit output it cannot finish: a template that
|
|
// fails partway through must yield a 500 and a body carrying none of
|
|
// the content emitted before the failure. Against a renderer that
|
|
// executes straight into the ResponseWriter this fails on both
|
|
// counts, returning 200 with the prefix already flushed.
|
|
func TestRenderTemplateMidRenderErrorSendsNoPartialBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, &h)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
h.AddTemplateForTest("failing.html", template.Must(
|
|
template.New("failing").Parse(
|
|
`{{.Data.Prefix}}{{.Data.Boom}}TAIL`,
|
|
),
|
|
))
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.RenderTemplateForTest(
|
|
w, req, "failing.html", midRenderFailure{},
|
|
)
|
|
|
|
assert.Equal(
|
|
t, http.StatusInternalServerError, w.Code,
|
|
"a failed render must report a 500",
|
|
)
|
|
assert.NotContains(
|
|
t, w.Body.String(), partialPageMarker,
|
|
"the response must carry no part of the aborted page",
|
|
)
|
|
assert.Contains(
|
|
t, w.Body.String(), "500 Internal Server Error",
|
|
"a failed render must answer with the error page",
|
|
)
|
|
}
|
|
|
|
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Empty expiry: the keep-forever default, empty config.
|
|
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest("")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, errMsg)
|
|
assert.Empty(t, cfg)
|
|
|
|
// Explicit never is stored as config.
|
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("never")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, errMsg)
|
|
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
|
|
|
// A positive duration is stored as config.
|
|
cfg, errMsg, err = handlers.BuildDatabaseTargetConfigForTest("720h")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, errMsg)
|
|
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
|
}
|
|
|
|
func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
|
cfg, errMsg, err := handlers.BuildDatabaseTargetConfigForTest(bad)
|
|
|
|
require.NoError(t, err)
|
|
assert.Contains(
|
|
t, errMsg, "Invalid archive expiry",
|
|
"expiry %q should be refused", bad,
|
|
)
|
|
assert.Empty(t, cfg)
|
|
}
|
|
}
|