check / check (push) Waiting to run
A target's settings move from the per-type JSON string in targets.config to columns: url, headers, timeout_seconds, forward_query, archive_expiry and archive_rotation. A column a type does not use stays empty. headers holds the header map as JSON text through GORM's JSON serializer, and archive_expiry holds a time.Duration, 0 meaning never. The forms check each setting once, with the same refusals and messages, and the edit form is filled from the columns, so the per-type config structs, their parsers and the "could not be read" branches are gone. A delivery task carries the six settings in place of the JSON string; tests check that each copy into a task, and back out of one, keeps all six. Model: opus-5-5
586 lines
14 KiB
Go
586 lines
14 KiB
Go
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))
|
|
}
|
|
}
|