check / check (push) Waiting to run
The delete prompts on the webhook page were inline onsubmit handlers. Each delete form now carries its question in a data-confirm attribute, which one submit listener in static/js/app.js asks. The three style attributes became Tailwind classes, with tailwind.css regenerated, and the x-cloak rule moved to style.css. The policy now sends script-src 'self' and style-src 'self'. The browser test now answers a target's delete prompt no and then yes, and also loads the sign-in page, the webhook list, the settings and profile pages and the 404 error page under the policy. The README states the policy as sent. Model: opus-5-5
378 lines
9.7 KiB
Go
378 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="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"?")
|
|
}
|