check / check (push) Waiting to run
The form's reason and values now come from the targetForm component, loaded from the section's data attributes after a refusal and emptied by Cancel, which also resets the form. Each type's fields used to be recreated with the refused values written into the markup, so they came back after Cancel. The browser test checks this after a refusal. A target configuration that cannot be encoded is again a logged 500 with the generic error page, on the add and the edit path; only refusals of submitted values come back on the form. The README paragraph on the browser test is re-wrapped at 80 columns and names Cancel at the type step. Model: opus-5-5
474 lines
11 KiB
Go
474 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, 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)
|
|
}
|
|
}
|