check / check (push) Successful in 3m15s
The database target type is called an archive on its badge, in the add target form's type list and on its edit page, and its settings read "Archive expiry" and "Archive rotation" everywhere, the new webhook page included. The retry field is labelled "Delivery attempts", with its help text and error messages to match, on both target forms and in the target list. The navbar says "Sign out" and the sign-in page "Sign in". The resubmit notice says "webhook". The stored values (`database`, `max_retries`) and their meaning are unchanged. Model: opus-5-5
379 lines
9.7 KiB
Go
379 lines
9.7 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"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 a target with a stored config
|
|
// blob and returns it.
|
|
func seedConfiguredTarget(
|
|
t *testing.T,
|
|
db *database.Database,
|
|
webhookID string,
|
|
targetType database.TargetType,
|
|
config string,
|
|
) *database.Target {
|
|
t.Helper()
|
|
|
|
tgt := &database.Target{
|
|
WebhookID: webhookID,
|
|
Name: "t-" + string(targetType),
|
|
Type: targetType,
|
|
Active: true,
|
|
Config: config,
|
|
}
|
|
|
|
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_MasksSlackWebhookURL is the
|
|
// load-bearing regression test for the credential leak: the
|
|
// rendered page must show the Slack target without any of the
|
|
// secret path segments of its webhook URL.
|
|
func TestHandleSourceDetail_MasksSlackWebhookURL(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.TargetTypeSlack,
|
|
`{"webhookUrl":"`+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.NotContains(t, body, "webhookUrl")
|
|
|
|
assert.Contains(t, body, "Webhook URL")
|
|
assert.Contains(t, body, "https://hooks.slack.com/...")
|
|
}
|
|
|
|
// TestHandleSourceDetail_MasksHTTPDestinationURL is the
|
|
// regression test for the same leak reached through the http
|
|
// target: its destination is routinely an incoming-webhook
|
|
// endpoint whose path segments are the credential, so the
|
|
// rendered page must not contain them.
|
|
func TestHandleSourceDetail_MasksHTTPDestinationURL(
|
|
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,
|
|
`{"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, "Destination URL")
|
|
assert.Contains(t, body, "https://hooks.slack.com/...")
|
|
}
|
|
|
|
// TestHandleSourceDetail_RendersNamedTargetFields proves the
|
|
// other target types render labelled fields rather than the
|
|
// stored blob.
|
|
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,
|
|
`{"url":"https://example.com/hook","timeout":30,`+
|
|
`"headers":{"Authorization":"Bearer sekrit"}}`,
|
|
)
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID,
|
|
database.TargetTypeDatabase,
|
|
`{"expiry":"720h"}`,
|
|
)
|
|
seedConfiguredTarget(
|
|
t, db, wh.ID,
|
|
database.TargetType("carrier-pigeon"),
|
|
`{"beak":"sharp"}`,
|
|
)
|
|
|
|
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 the
|
|
// stored blob.
|
|
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="mx-auto px-6 py-8" style="max-width: 108rem"`,
|
|
)
|
|
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. The template
|
|
// writes the slashes after http: as \/, which the browser reads as /.
|
|
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,
|
|
`Delete webhook "delete-me"?\n\n`+
|
|
`This deletes its stored events (3) and their deliveries. `+
|
|
`Any archive files it wrote are kept.`)
|
|
assert.Contains(t, body,
|
|
`Delete entrypoint "Stripe"?\n\n`+
|
|
`Senders using its URL get an error from now on, `+
|
|
`and the URL cannot be restored.`)
|
|
assert.Contains(t, body,
|
|
`Delete entrypoint "http:\/\/example.com/h/`+
|
|
unnamed.Path+`"?`)
|
|
assert.Contains(t, body,
|
|
`Delete target "t-log"?\n\n`+
|
|
`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 script, which the
|
|
// browser reads back as the name typed: each quote and angle bracket
|
|
// as a \u escape, the slash as \/, the newline as \n and the backslash
|
|
// doubled. An unescaped newline would break the prompt's script, and
|
|
// the form would then submit without asking.
|
|
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\\u0027s \\u0022best\\u0022 \\\\ hook"+
|
|
"\\u003c\\/script\\u003e\\nline two"?")
|
|
}
|