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
355 lines
9.3 KiB
Go
355 lines
9.3 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm/clause"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
// The secret path segments of a Slack incoming webhook URL.
|
|
// Holding them is enough to post to the channel forever, so
|
|
// they must never reach the rendered page.
|
|
const (
|
|
slackSecretPath = "/services/T00000000/B00000000/" +
|
|
"XXXXXXXXXXXXXXXXXXXXXXXX"
|
|
slackWebhookURL = "https://hooks.slack.com" +
|
|
slackSecretPath
|
|
)
|
|
|
|
// seedConfiguredTarget inserts an active target of targetType with
|
|
// the settings (URL to ArchiveRotation) that settings carries, and
|
|
// returns it.
|
|
func seedConfiguredTarget(
|
|
t *testing.T,
|
|
db *database.Database,
|
|
webhookID string,
|
|
targetType database.TargetType,
|
|
settings database.Target,
|
|
) *database.Target {
|
|
t.Helper()
|
|
|
|
tgt := &settings
|
|
tgt.WebhookID = webhookID
|
|
tgt.Name = "t-" + string(targetType)
|
|
tgt.Type = targetType
|
|
tgt.Active = true
|
|
tgt.MaxAttempts = 1
|
|
|
|
require.NoError(
|
|
t,
|
|
db.DB().Omit(clause.Associations).Create(tgt).Error,
|
|
)
|
|
|
|
return tgt
|
|
}
|
|
|
|
// renderSourceDetailPage runs the real source detail handler
|
|
// for a webhook and returns the rendered HTML.
|
|
func renderSourceDetailPage(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
webhookID string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
w := serveSourceDetailPage(t, h, sess, webhookID)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
return w.Body.String()
|
|
}
|
|
|
|
// serveSourceDetailPage runs the real source detail handler for a
|
|
// webhook and returns its response, whatever its status.
|
|
func serveSourceDetailPage(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
webhookID string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet,
|
|
"/hook/"+webhookID,
|
|
nil,
|
|
)
|
|
|
|
for _, c := range authenticatedCookies(
|
|
t, sess, deleteTestUserID, deleteTestUsername,
|
|
) {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add(paramSourceID, webhookID)
|
|
|
|
req = req.WithContext(
|
|
context.WithValue(
|
|
req.Context(), chi.RouteCtxKey, rctx,
|
|
),
|
|
)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.HandleSourceDetail().ServeHTTP(w, req)
|
|
|
|
return w
|
|
}
|
|
|
|
// TestHandleSourceDetail_MasksTargetURL is the load-bearing
|
|
// regression test for the credential leak: the rendered page must
|
|
// show a Slack target, and an http target, without any of the secret
|
|
// path segments of its URL. An http target's destination is routinely
|
|
// an incoming-webhook endpoint too, so the same Slack URL serves for
|
|
// both.
|
|
func TestHandleSourceDetail_MasksTargetURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
labels := map[database.TargetType]string{
|
|
database.TargetTypeSlack: "Webhook URL",
|
|
database.TargetTypeHTTP: "Destination URL",
|
|
}
|
|
|
|
for targetType, label := range labels {
|
|
t.Run(string(targetType), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID, targetType,
|
|
database.Target{URL: slackWebhookURL},
|
|
)
|
|
|
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
|
|
assert.NotContains(t, body, slackSecretPath)
|
|
assert.NotContains(t, body, "T00000000")
|
|
assert.NotContains(t, body, "B00000000")
|
|
assert.NotContains(
|
|
t, body, "XXXXXXXXXXXXXXXXXXXXXXXX",
|
|
)
|
|
|
|
assert.Contains(t, body, label)
|
|
assert.Contains(t, body, "https://hooks.slack.com/...")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleSourceDetail_RendersNamedTargetFields proves the
|
|
// other target types render labelled fields rather than the
|
|
// stored values.
|
|
func TestHandleSourceDetail_RendersNamedTargetFields(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID,
|
|
database.TargetTypeHTTP,
|
|
database.Target{
|
|
URL: "https://example.com/hook",
|
|
TimeoutSeconds: 30,
|
|
Headers: map[string]string{"X-Api-Key": "sekrit"},
|
|
},
|
|
)
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID,
|
|
database.TargetTypeDatabase,
|
|
database.Target{
|
|
ArchiveExpiry: 720 * time.Hour,
|
|
ArchiveRotation: rotationNone,
|
|
},
|
|
)
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID,
|
|
database.TargetType("carrier-pigeon"),
|
|
database.Target{URL: "https://pigeon.example/beak"},
|
|
)
|
|
|
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
|
|
assert.Contains(t, body, "Destination URL")
|
|
assert.Contains(t, body, "https://example.com/...")
|
|
assert.Contains(t, body, "Timeout")
|
|
assert.Contains(t, body, "1 configured")
|
|
assert.NotContains(t, body, "sekrit")
|
|
|
|
// The database type is called an archive: on its badge, in the
|
|
// add target form's type list and in its settings.
|
|
list := targetList(t, body)
|
|
assert.Contains(t, list, "t-database archive Active")
|
|
assert.Contains(t, list, "Archive expiry: 30 days")
|
|
assert.Contains(t, list, "Archive rotation: none")
|
|
assert.Contains(t, body, `<option value="database">Archive</option>`)
|
|
|
|
// An unknown type gets the neutral placeholder, never its
|
|
// stored URL.
|
|
assert.Contains(t, body, "(unavailable)")
|
|
assert.NotContains(t, body, "beak")
|
|
}
|
|
|
|
// TestHandleSourceDetail_FitsWideAndNarrowWindows pins the webhook
|
|
// page's maximum width at 108rem (1728 px), half again the 72rem of
|
|
// max-w-6xl that the webhook list and the event log use, so an
|
|
// entrypoint URL fits on one line in a 1920-pixel window; and the
|
|
// wrapping of its title row, so the buttons beside the title do not
|
|
// push a phone-width window into scrolling sideways.
|
|
func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
|
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
|
|
assert.Contains(
|
|
t, body,
|
|
`<div class="max-w-[108rem] mx-auto px-6 py-8">`,
|
|
)
|
|
assert.Contains(
|
|
t, body,
|
|
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
|
|
)
|
|
}
|
|
|
|
// TestHandleSourceDetail_DeletePromptsNameWhatIsLost checks that each
|
|
// delete prompt on the webhook page names the webhook, entrypoint or
|
|
// target and says what deleting it loses, that the webhook's gives its
|
|
// number of stored events (5 received, 2 removed by retention, so 3,
|
|
// the statistics pane's "Within retention" figure), and that an
|
|
// entrypoint with no description is named by its URL. Each prompt is
|
|
// its form's data-confirm attribute, where is a line break.
|
|
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, database.AddEventTotals(
|
|
webhookDB, database.EventTotals{Events: 5, EventsRemoved: 2},
|
|
))
|
|
|
|
unnamed := seedEntrypoint(t, db, wh.ID)
|
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(
|
|
&database.Entrypoint{
|
|
WebhookID: wh.ID,
|
|
Path: "described-" + wh.ID,
|
|
Description: "Stripe",
|
|
Active: true,
|
|
},
|
|
).Error)
|
|
seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
|
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
|
|
assert.Contains(t, body,
|
|
`data-confirm="Delete webhook "delete-me"? `+
|
|
`This deletes its stored events (3) and their deliveries. `+
|
|
`Any archive files it wrote are kept."`)
|
|
assert.Contains(t, body,
|
|
`data-confirm="Delete entrypoint "Stripe"? `+
|
|
`Senders using its URL get an error from now on, `+
|
|
`and the URL cannot be restored."`)
|
|
assert.Contains(t, body,
|
|
`data-confirm="Delete entrypoint "http://example.com/h/`+
|
|
unnamed.Path+`"? `)
|
|
assert.Contains(t, body,
|
|
`data-confirm="Delete target "t-log"? `+
|
|
`Nothing more is delivered to it. `+
|
|
`Its past deliveries stay in the event log."`)
|
|
}
|
|
|
|
// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a
|
|
// webhook name with quotes, a backslash, a closing script tag and a
|
|
// newline reaches its delete prompt escaped for the attribute, which
|
|
// the browser reads back as the name typed: each quote and angle
|
|
// bracket as a character reference, the rest as typed. An unescaped
|
|
// double quote would end the attribute and cut the prompt short.
|
|
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := &database.Webhook{
|
|
UserID: deleteTestUserID,
|
|
Name: "Bob's \"best\" \\ hook</script>\nline two",
|
|
}
|
|
require.NoError(
|
|
t, db.DB().Omit(clause.Associations).Create(wh).Error,
|
|
)
|
|
|
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
|
|
|
assert.Contains(t, body,
|
|
"Delete webhook "Bob's "best" \\ hook"+
|
|
"</script>\nline two"?")
|
|
}
|