Some checks failed
check / check (push) Failing after 2m31s
A receiver URL was a bare v4 UUID and nothing else: anyone who learned it could store events and, because inbound headers are forwarded to targets almost verbatim, choose what the downstream service received. Entrypoints gain an optional scheme/secret pair. GitHub's X-Hub-Signature-256 (HMAC-SHA256 hex over the raw body) and GitLab's X-Gitlab-Token (plain shared token) are supported; both compare with hmac.Equal. With nothing configured an entrypoint behaves exactly as before, which is also where every pre-existing row lands after AutoMigrate adds the columns. Verification runs after the capped body read and before the first write, so a rejected request leaves no event row, no delivery row and no delivery task. A configuration the receiver cannot apply — unknown scheme, or one half of the pair missing — is refused with a 500 rather than falling back to unverified. The secret is credential-bearing and is stored in the clear because HMAC needs the key itself. It is excluded from JSON, kept out of templates by a new handlers.EntrypointView projection, and absent from every log line including the rejection path. The UI sets and rotates it through one form that never renders the stored value. Under the GitLab scheme the signature header is the secret rather than a digest over the request, so an accepted request's headers are cloned and the configured scheme's credential header dropped before they are serialized onto the event. Stored headers are persisted verbatim in the per-webhook database and replayed onto every outbound delivery, so keeping the token would put it in every backup and hand every target operator the means to forge signed requests to the entrypoint it authenticates. Stripping sits once above the first write rather than at each egress, and is driven by the scheme's own description with stripping as the default: a scheme added later is covered unless it declares its header a digest, as GitHub's HMAC over the body does. An entrypoint holding one half of the pair now renders as misconfigured rather than as unverified, and the scheme selector follows the stored scheme so such a row no longer marks two options selected.
305 lines
8.5 KiB
Go
305 lines
8.5 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
"sneak.berlin/go/webhooker/internal/signature"
|
|
)
|
|
|
|
// Template data keys the page templates read. The handlers package has
|
|
// its own unexported constants for these; this is the external test
|
|
// package, so it needs its own.
|
|
const (
|
|
dataKeyWebhook = "Webhook"
|
|
dataKeyError = "Error"
|
|
)
|
|
|
|
// testWebhookID is the identifier given to the webhook under test on
|
|
// pages that render one.
|
|
const testWebhookID = "wh-1"
|
|
|
|
// renderPage renders a page template through the real template set as
|
|
// an authenticated user and returns the resulting HTML.
|
|
func renderPage(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
page string,
|
|
data map[string]any,
|
|
) string {
|
|
t.Helper()
|
|
|
|
cookies := authenticatedCookies(t, sess, "test-user-id", "testuser")
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/", nil,
|
|
)
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
h.RenderTemplateForTest(w, req, page, data)
|
|
|
|
return w.Body.String()
|
|
}
|
|
|
|
// TestNavbarUsesWebhookTerminology pins the user-visible navigation
|
|
// label to "Webhooks". The /sources route is deliberately unchanged, so
|
|
// the assertion targets the link text rather than the href.
|
|
func TestNavbarUsesWebhookTerminology(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
var sess *session.Session
|
|
|
|
app := newTestApp(t, &h, &sess)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
// One item, so the list body renders too: it calls
|
|
// WebhookListItem.RetentionLabel, promoted from the embedded
|
|
// Webhook and therefore a pointer method. An empty list would
|
|
// skip that call and hide a template error behind the
|
|
// navigation assertions below.
|
|
item := handlers.WebhookListItem{}
|
|
item.Name = "wh"
|
|
item.ID = testWebhookID
|
|
item.RetentionDays = 14
|
|
|
|
body := renderPage(t, h, sess, "sources_list.html", map[string]any{
|
|
"Webhooks": []handlers.WebhookListItem{item},
|
|
})
|
|
|
|
assert.Contains(t, body, "Retention: 14 days")
|
|
assert.Contains(t, body, `class="btn-text">Webhooks</a>`)
|
|
assert.Contains(
|
|
t, body, `class="btn-text w-full text-left">Webhooks</a>`,
|
|
)
|
|
assert.Contains(
|
|
t, body,
|
|
`<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>`,
|
|
)
|
|
assert.NotContains(
|
|
t, body, ">Sources<",
|
|
"no user-visible element may still be labelled Sources",
|
|
)
|
|
assert.Contains(
|
|
t, body, `href="/sources"`,
|
|
"the /sources route itself must not change",
|
|
)
|
|
}
|
|
|
|
// TestEditPageUsesWebhookTerminology pins the edit page's heading and
|
|
// its back link. The link's href still points at /source/{id}, which is
|
|
// intentional: only user-visible copy changes.
|
|
func TestEditPageUsesWebhookTerminology(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
var sess *session.Session
|
|
|
|
app := newTestApp(t, &h, &sess)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
// The webhook goes in as a pointer because source_edit.html calls
|
|
// Webhook.RetentionLabel, a pointer method: a map element is not
|
|
// addressable, so a value here renders an error instead of the
|
|
// page.
|
|
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
|
|
webhook.ID = testWebhookID
|
|
|
|
body := renderPage(t, h, sess, "source_edit.html", map[string]any{
|
|
dataKeyWebhook: webhook,
|
|
dataKeyError: "",
|
|
})
|
|
|
|
assert.Contains(t, body, "Edit Webhook")
|
|
assert.NotContains(t, body, ">Sources<")
|
|
assert.Contains(t, body, `href="/source/wh-1"`)
|
|
}
|
|
|
|
// TestCreateFormRetentionCopyMatchesBehaviour pins the create form's
|
|
// retention copy to what the code does: the reaper permanently deletes
|
|
// events past the cutoff, an empty field falls back to
|
|
// DefaultRetentionDays, and 0 is rewritten to the retain-forever
|
|
// sentinel by Webhook.BeforeSave.
|
|
func TestCreateFormRetentionCopyMatchesBehaviour(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
var sess *session.Session
|
|
|
|
app := newTestApp(t, &h, &sess)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
|
|
"Name": "",
|
|
"Description": "",
|
|
"DefaultRetentionDays": database.DefaultRetentionDays,
|
|
dataKeyError: "",
|
|
})
|
|
|
|
assert.Contains(
|
|
t, body,
|
|
"permanently deletes events older than this",
|
|
"the form must say retention is enforced by deletion",
|
|
)
|
|
assert.Contains(t, body, "Enter 0 to retain events forever")
|
|
assert.Contains(
|
|
t, body,
|
|
"leave blank to use the default of "+
|
|
strconv.Itoa(database.DefaultRetentionDays)+" days",
|
|
"blank means the default, not forever",
|
|
)
|
|
}
|
|
|
|
// TestEditFormRetentionCopyMatchesBehaviour pins the edit form's
|
|
// retention copy, including that it states the stored policy via
|
|
// RetentionLabel and that an empty field leaves that policy unchanged
|
|
// rather than meaning forever.
|
|
func TestEditFormRetentionCopyMatchesBehaviour(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
var sess *session.Session
|
|
|
|
app := newTestApp(t, &h, &sess)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
finite := &database.Webhook{Name: "wh", RetentionDays: 14}
|
|
finite.ID = testWebhookID
|
|
|
|
body := renderPage(t, h, sess, "source_edit.html", map[string]any{
|
|
dataKeyWebhook: finite,
|
|
dataKeyError: "",
|
|
})
|
|
|
|
assert.Contains(t, body, "Currently 14 days.")
|
|
assert.Contains(
|
|
t, body,
|
|
"permanently deletes events older than this",
|
|
)
|
|
assert.Contains(t, body, "Enter 0 to retain events forever")
|
|
assert.Contains(
|
|
t, body,
|
|
"leave blank to keep the current setting",
|
|
"blank means unchanged, not forever",
|
|
)
|
|
|
|
forever := &database.Webhook{
|
|
Name: "wh",
|
|
RetentionDays: database.RetentionForeverDays,
|
|
}
|
|
forever.ID = "wh-2"
|
|
|
|
foreverBody := renderPage(
|
|
t, h, sess, "source_edit.html", map[string]any{
|
|
dataKeyWebhook: forever,
|
|
dataKeyError: "",
|
|
},
|
|
)
|
|
|
|
assert.Contains(
|
|
t, foreverBody, "Currently forever.",
|
|
"a retain-forever webhook must not read as a day count",
|
|
)
|
|
assert.Contains(
|
|
t, foreverBody,
|
|
"No events are deleted while retention is set to forever",
|
|
)
|
|
assert.NotContains(
|
|
t, foreverBody,
|
|
"permanently deletes events older than this",
|
|
"the reaper skips retain-forever webhooks, so the form "+
|
|
"must not claim it deletes their events",
|
|
)
|
|
}
|
|
|
|
// TestEntrypointCopyButtonIsProgressiveEnhancement proves the copy
|
|
// affordance degrades: the button ships with the hidden attribute, so a
|
|
// browser that never runs app.js shows no dead control, and the URL is
|
|
// rendered as ordinary selectable text either way.
|
|
func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
var sess *session.Session
|
|
|
|
app := newTestApp(t, &h, &sess)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
entrypoint := database.Entrypoint{Path: "abc123"}
|
|
entrypoint.ID = "ep-1"
|
|
|
|
// The webhook goes in as a pointer because source_detail.html
|
|
// calls Webhook.RetentionLabel, a pointer method: a map element
|
|
// is not addressable, so a value here aborts execution partway
|
|
// down the page, after the copy button has already been flushed
|
|
// to the response.
|
|
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
|
|
webhook.ID = testWebhookID
|
|
webhook.CreatedAt = time.Date(
|
|
2026, time.January, 2, 3, 4, 5, 0, time.UTC,
|
|
)
|
|
|
|
body := renderPage(t, h, sess, "source_detail.html", map[string]any{
|
|
dataKeyWebhook: webhook,
|
|
// The handler passes projected views, never raw rows — an
|
|
// entrypoint carries its shared secret and a target its
|
|
// stored credential — so the test data has that same shape.
|
|
"Entrypoints": handlers.NewEntrypointViews(
|
|
[]database.Entrypoint{entrypoint},
|
|
),
|
|
"Targets": delivery.NewTargetViews(nil),
|
|
"SignatureSchemes": signature.Schemes(),
|
|
"Events": []database.Event{},
|
|
"BaseURL": "https://hooks.example.com",
|
|
})
|
|
|
|
assert.Contains(
|
|
t, body,
|
|
`<code id="entrypoint-url-ep-1"`,
|
|
)
|
|
assert.Contains(t, body, "https://hooks.example.com/webhook/abc123")
|
|
assert.Contains(
|
|
t, body,
|
|
`hidden data-copy-target="entrypoint-url-ep-1"`,
|
|
"the button must start hidden and be revealed by script",
|
|
)
|
|
|
|
// renderTemplate streams to the ResponseWriter, so an abort
|
|
// midway still leaves everything above it in the body. This pins
|
|
// content from the last line of the template, which is below the
|
|
// assertions above: without it, a page that renders the copy
|
|
// button and then 500s passes.
|
|
assert.Contains(
|
|
t, body, "Retention: 14 days",
|
|
"the page must render to completion, not abort partway",
|
|
)
|
|
}
|