check / check (push) Successful in 3m10s
The webhook page and everything under it move from /source/ID to /hook/ID; the list and new-webhook form from /sources to /hooks and /hooks/new; the event log from .../logs to /hook/ID/events; entrypoint URLs, the ones senders post to, from /webhook/UUID to /h/UUID. The old paths are simply gone, per the owner's ruling. Every link, redirect, form action, test, comment and README mention follows; every link to the event log, and its title and heading, read "Full Event Log". Keeping the UUID out of logs, metrics labels and error reports, and the receiver rate limits, key on the route itself, so they cover /h/ unchanged. Routed tests follow every link and form the pages render to a moved page. Model: opus-5-5
369 lines
8.4 KiB
Go
369 lines
8.4 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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/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
|
|
}
|
|
|
|
// recordingEvictor is a delivery.WebhookEvictor that records
|
|
// the webhook ids it was asked to evict, so a test can prove
|
|
// that a deletion path reached the delivery engine.
|
|
type recordingEvictor struct {
|
|
mu sync.Mutex
|
|
evicted []string
|
|
}
|
|
|
|
func (r *recordingEvictor) EvictWebhook(webhookID string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.evicted = append(r.evicted, webhookID)
|
|
}
|
|
|
|
// Evicted returns a copy of the recorded webhook ids.
|
|
func (r *recordingEvictor) Evicted() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
out := make([]string, len(r.evicted))
|
|
copy(out, r.evicted)
|
|
|
|
return out
|
|
}
|
|
|
|
func newTestApp(
|
|
t *testing.T,
|
|
targets ...any,
|
|
) *fxtest.App {
|
|
t.Helper()
|
|
|
|
return fxtest.New(
|
|
t,
|
|
fx.Provide(
|
|
globals.New,
|
|
logger.New,
|
|
func() *config.Config {
|
|
return &config.Config{
|
|
DataDir: t.TempDir(),
|
|
}
|
|
},
|
|
database.New,
|
|
database.NewWebhookDBManager,
|
|
healthcheck.New,
|
|
session.New,
|
|
func() *recordingNotifier {
|
|
return &recordingNotifier{}
|
|
},
|
|
func(n *recordingNotifier) delivery.Notifier {
|
|
return n
|
|
},
|
|
func() *recordingEvictor {
|
|
return &recordingEvictor{}
|
|
},
|
|
func(r *recordingEvictor) delivery.WebhookEvictor {
|
|
return r
|
|
},
|
|
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)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
cfg, err := h.BuildSlackTargetConfigForTest(
|
|
w, req, "http://93.184.216.34/services/T00/B00/xxx",
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
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)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, "/", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
cfg, err := h.BuildSlackTargetConfigForTest(
|
|
w, req, "http://169.254.169.254/latest/meta-data/",
|
|
)
|
|
|
|
require.Error(t, err)
|
|
assert.Empty(t, cfg)
|
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
}
|
|
|
|
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.Equal(
|
|
t, "Internal server error\n", w.Body.String(),
|
|
"the response must carry no part of the aborted page",
|
|
)
|
|
}
|
|
|
|
func TestBuildDatabaseTargetConfig_Valid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, &h)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
// Empty expiry: the keep-forever default, empty config.
|
|
w := httptest.NewRecorder()
|
|
cfg, err := h.BuildDatabaseTargetConfigForTest(w, "")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, cfg)
|
|
|
|
// Explicit never is stored as config.
|
|
w = httptest.NewRecorder()
|
|
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "never")
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, `{"expiry":"never"}`, cfg)
|
|
|
|
// A positive duration is stored as config.
|
|
w = httptest.NewRecorder()
|
|
cfg, err = h.BuildDatabaseTargetConfigForTest(w, "720h")
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, `{"expiry":"720h"}`, cfg)
|
|
}
|
|
|
|
func TestBuildDatabaseTargetConfig_RejectsBadExpiry(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, &h)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
for _, bad := range []string{"nonsense", "7d", "-5h"} {
|
|
w := httptest.NewRecorder()
|
|
cfg, err := h.BuildDatabaseTargetConfigForTest(w, bad)
|
|
|
|
require.Error(t, err, "expiry %q", bad)
|
|
assert.Empty(t, cfg)
|
|
assert.Equal(
|
|
t, http.StatusBadRequest, w.Code,
|
|
"expiry %q should be rejected with 400", bad,
|
|
)
|
|
}
|
|
}
|