Files
webhooker/internal/handlers/handlers_test.go
T
clawbot 93f7a2b0f4 Targets section: one Add, then a type and Next, then its fields (closes #370)
The webhook page's targets section lists only its targets until Add is
clicked. Add shows a choice of target type with Next; Next shows only
that type's fields, with Save and Cancel. The `database` and `log`
types have no URL field, and the `slack` form gains max retries.

A refused target now shows the webhook page again with the form open on
its type, the values entered and the reason, instead of a bare text
page. Target validation returns that message rather than writing the
response; newTarget validates a whole new target for reuse by the
new-webhook page. The edit page still answers a refusal in plain text.

Model: opus-5-5
2026-10-02 19:51:51 +00:00

468 lines
11 KiB
Go

package handlers_test
import (
"context"
"errors"
"fmt"
"html/template"
"net/http"
"net/http/httptest"
"sync"
"testing"
"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
}
// 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
},
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 := h.BuildSlackTargetConfigForTest(
t.Context(), "http://93.184.216.34/services/T00/B00/xxx",
)
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 := h.BuildSlackTargetConfigForTest(
t.Context(), "http://169.254.169.254/latest/meta-data/",
)
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 := handlers.BuildDatabaseTargetConfigForTest("")
assert.Empty(t, errMsg)
assert.Empty(t, cfg)
// Explicit never is stored as config.
cfg, errMsg = handlers.BuildDatabaseTargetConfigForTest("never")
assert.Empty(t, errMsg)
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
// A positive duration is stored as config.
cfg, errMsg = handlers.BuildDatabaseTargetConfigForTest("720h")
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 := handlers.BuildDatabaseTargetConfigForTest(bad)
assert.Contains(
t, errMsg, "Invalid archive expiry",
"expiry %q should be refused", bad,
)
assert.Empty(t, cfg)
}
}