package handlers_test import ( "context" "errors" "fmt" "html/template" "log/slog" "net/http" "net/http/httptest" "strconv" "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/archive" "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 an archive.Archives that records what it // was asked to do, so a test can prove that a deletion or rename // path reached the archive writers. After FailRenames, every // rename of that target fails with the given error. After // BlockNextRename, the next rename is recorded and then waits. // RenameLock returns a plain mutex, as in archive.Writers. type recordingArchives struct { renameMu sync.Mutex 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 archive writers return when a file // already has an archive's new name, here archive-taken.db. var errNameTaken = fmt.Errorf( "%w: archive-taken.db", archive.ErrNameTaken, ) // archiveRename is one recorded Rename call. type archiveRename struct { TargetID string WebhookName string TargetName string } func (r *recordingArchives) RenameLock() *sync.Mutex { return &r.renameMu } 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(l *logger.Logger) *slog.Logger { return l.Get() }, 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) archive.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 TestSetSlackTargetSettings_AcceptsPublicURL(t *testing.T) { t.Parallel() var h *handlers.Handlers app := newTestApp(t, &h) app.RequireStart() t.Cleanup(app.RequireStop) const hookURL = "http://93.184.216.34/services/T00/B00/xxx" var target database.Target errMsg := h.SetSlackTargetSettingsForTest(t.Context(), &target, hookURL) assert.Empty(t, errMsg) assert.Equal(t, hookURL, target.URL) } func TestSetSlackTargetSettings_RejectsReservedURL(t *testing.T) { t.Parallel() var h *handlers.Handlers app := newTestApp(t, &h) app.RequireStart() t.Cleanup(app.RequireStop) var target database.Target errMsg := h.SetSlackTargetSettingsForTest( t.Context(), &target, "http://169.254.169.254/latest/meta-data/", ) assert.Contains(t, errMsg, "Invalid target URL") assert.Empty(t, target.URL) } 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 TestSetDatabaseTargetSettings_Valid(t *testing.T) { t.Parallel() cases := []struct { expiry, rotation string wantExpiry time.Duration wantRotation string }{ // Empty expiry and rotation: the keep-forever, one-file // default. {"", "", 0, rotationNone}, {expiryNever, "", 0, rotationNone}, {" 36h ", "", 36 * time.Hour, rotationNone}, {"", rotationDaily, 0, rotationDaily}, {"90m", rotationHourly, 90 * time.Minute, rotationHourly}, } for _, tc := range cases { var target database.Target errMsg := handlers.SetDatabaseTargetSettingsForTest( &target, tc.expiry, tc.rotation, ) assert.Empty(t, errMsg, tc) assert.Equal(t, tc.wantExpiry, target.ArchiveExpiry, tc) assert.Equal(t, tc.wantRotation, target.ArchiveRotation, tc) } } // TestSetDatabaseTargetSettings_RejectsBadExpiry pins each refusal's // whole message, as the form shows it. func TestSetDatabaseTargetSettings_RejectsBadExpiry( t *testing.T, ) { t.Parallel() cases := map[string]string{ "nonsense": `Invalid archive expiry: expiry must be "never" or ` + `a Go duration such as "720h": time: invalid duration ` + `"nonsense"`, "7d": `Invalid archive expiry: expiry must be "never" or a Go ` + `duration such as "720h": time: unknown unit "d" in ` + `duration "7d"`, "-5h": `Invalid archive expiry: expiry must be a positive ` + `duration or "never": "-5h"`, } for bad, want := range cases { var target database.Target errMsg := handlers.SetDatabaseTargetSettingsForTest( &target, bad, "", ) assert.Equal(t, want, errMsg, "expiry %q", bad) } } func TestSetDatabaseTargetSettings_RejectsBadRotation( t *testing.T, ) { t.Parallel() for _, bad := range []string{"weekly", "Daily", " none"} { var target database.Target errMsg := handlers.SetDatabaseTargetSettingsForTest( &target, "", bad, ) assert.Equal( t, "Invalid archive rotation: rotation must be none, "+ "monthly, daily or hourly: "+strconv.Quote(bad), errMsg, ) } } // TestFormatArchiveExpiry covers the expiries the expiry choices do // not: TestArchiveExpiryChoices shows the edit form starting on each // choice. An expiry in whole hours is written in hours, and any other // as Go writes it, which the form accepts back unchanged. func TestFormatArchiveExpiry(t *testing.T) { t.Parallel() cases := map[time.Duration]string{ 0: expiryNever, 36 * time.Hour: "36h", 90 * time.Minute: "1h30m0s", } for expiry, want := range cases { assert.Equal(t, want, handlers.FormatArchiveExpiryForTest(expiry)) } }