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, ``) // 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 and of long values, so neither the buttons // beside the title nor a long name 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, `
`, ) } // 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\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"?") }