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, ``) // 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, `
`, ) 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. 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\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"?") }