check / check (push) Successful in 3m21s
While an http or slack target's circuit breaker is open, the target's row on the webhook page says its deliveries are paused until the cooldown ends, in UTC and from now. Each of its retrying deliveries shows as waiting in the event log and on the event's page, until the later of the cooldown's end and the end of its own backoff. While the breaker is half-open, the row says deliveries are held while one delivery tests the target, with no time, and deliveries keep their plain status. The engine gains one read, StateAndCooldown(targetID), taking a breaker's state and remaining cooldown under one lock; the handlers reach it through a one-method interface wired like Archives. Model: opus-5-5
515 lines
12 KiB
Go
515 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 that reports, for
|
|
// each target, the circuit state and cooldown a test gave it with Set,
|
|
// and a closed breaker for any other target.
|
|
type testCircuitBreakers struct {
|
|
mu sync.Mutex
|
|
states map[string]delivery.CircuitState
|
|
cooldowns map[string]time.Duration
|
|
}
|
|
|
|
// Set makes the target's breaker read as state, with cooldown left.
|
|
func (b *testCircuitBreakers) Set(
|
|
targetID string, state delivery.CircuitState, cooldown time.Duration,
|
|
) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if b.states == nil {
|
|
b.states = map[string]delivery.CircuitState{}
|
|
b.cooldowns = map[string]time.Duration{}
|
|
}
|
|
|
|
b.states[targetID] = state
|
|
b.cooldowns[targetID] = cooldown
|
|
}
|
|
|
|
func (b *testCircuitBreakers) StateAndCooldown(
|
|
targetID string,
|
|
) (delivery.CircuitState, time.Duration) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
return b.states[targetID], b.cooldowns[targetID]
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|