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, 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) } }