package handlers_test import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/session" ) // submitEntrypointSecret posts the signature configuration form for // an entrypoint and returns the recorder. func submitEntrypointSecret( t *testing.T, h *handlers.Handlers, cookies []*http.Cookie, webhookID, entrypointID, scheme, secret string, ) *httptest.ResponseRecorder { t.Helper() form := url.Values{} form.Set("signature_scheme", scheme) form.Set("secret", secret) req := formRequest( "/source/"+webhookID+"/entrypoints/"+ entrypointID+"/secret", cookies, form, map[string]string{ paramSourceID: webhookID, entrypointIDParam: entrypointID, }, ) w := httptest.NewRecorder() h.HandleEntrypointSecret().ServeHTTP(w, req) return w } // reloadEntrypoint reads an entrypoint back from the database, // including the columns the model keeps out of JSON. func reloadEntrypoint( t *testing.T, db *database.Database, id string, ) database.Entrypoint { t.Helper() var ep database.Entrypoint require.NoError( t, db.DB().Where("id = ?", id).First(&ep).Error, ) return ep } // TestEntrypointSecretSetRotateAndRemove walks the whole lifecycle // the UI has to support: turning verification on, rotating the secret // to a new value, and turning it back off. func TestEntrypointSecretSetRotateAndRemove(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) cookies := authenticatedCookies( t, sess, deleteTestUserID, deleteTestUsername, ) wh := seedWebhook(t, db) ep := seedSignedEntrypoint( t, db, wh.ID, database.SignatureSchemeNone, "", ) // Set. w := submitEntrypointSecret( t, h, cookies, wh.ID, ep.ID, "github", inboundSecret, ) require.Equal(t, http.StatusSeeOther, w.Code) stored := reloadEntrypoint(t, db, ep.ID) assert.Equal( t, database.SignatureSchemeGitHub, stored.SignatureScheme, ) assert.Equal(t, inboundSecret, stored.SignatureSecret) assert.True(t, stored.SignatureConfigured()) // Rotate: a new secret and a different scheme in one submission. // The new value is submitted with surrounding whitespace, the way // a secret pasted out of a password manager arrives; storing that // verbatim would make every later request fail verification with // nothing visible on either side to explain it. const rotated = "QQROTATEDSECRETQQ" w = submitEntrypointSecret( t, h, cookies, wh.ID, ep.ID, "gitlab", " "+rotated+"\t", ) require.Equal(t, http.StatusSeeOther, w.Code) stored = reloadEntrypoint(t, db, ep.ID) assert.Equal( t, database.SignatureSchemeGitLab, stored.SignatureScheme, ) assert.Equal(t, rotated, stored.SignatureSecret) // Remove. The secret has to go with the scheme: a stored // credential nothing reads is one more copy to leak. w = submitEntrypointSecret(t, h, cookies, wh.ID, ep.ID, "", "") require.Equal(t, http.StatusSeeOther, w.Code) stored = reloadEntrypoint(t, db, ep.ID) assert.Equal( t, database.SignatureSchemeNone, stored.SignatureScheme, ) assert.Empty(t, stored.SignatureSecret) assert.False(t, stored.SignatureConfigured()) } // TestEntrypointSecretRejectsBadInput proves the form cannot create a // row the receiver would later have to refuse. Both rejections leave // the stored configuration untouched rather than half-applied. func TestEntrypointSecretRejectsBadInput(t *testing.T) { t.Parallel() cases := []struct { name string scheme string secret string }{ { name: "unsupported scheme", scheme: "stripe", secret: inboundSecret, }, { name: "scheme with no secret", scheme: "github", secret: "", }, { // Whitespace is stripped, so a secret of spaces is an // empty one. name: "scheme with blank secret", scheme: "github", secret: " ", }, } var ( h *handlers.Handlers sess *session.Session db *database.Database ) app := newTestApp(t, &h, &sess, &db) app.RequireStart() t.Cleanup(app.RequireStop) cookies := authenticatedCookies( t, sess, deleteTestUserID, deleteTestUsername, ) for _, tc := range cases { wh := seedWebhook(t, db) ep := seedSignedEntrypoint( t, db, wh.ID, database.SignatureSchemeGitLab, inboundSecret, ) w := submitEntrypointSecret( t, h, cookies, wh.ID, ep.ID, tc.scheme, tc.secret, ) assert.Equal( t, http.StatusBadRequest, w.Code, "case %s", tc.name, ) stored := reloadEntrypoint(t, db, ep.ID) assert.Equal( t, database.SignatureSchemeGitLab, stored.SignatureScheme, "case %s", tc.name, ) assert.Equal( t, inboundSecret, stored.SignatureSecret, "case %s", tc.name, ) } } // TestEntrypointSecretRequiresOwnership proves the configuration // endpoint is bound by the same ownership check as the rest of the // webhook's pages: another user's entrypoint is a 404, and the secret // is not touched. func TestEntrypointSecretRequiresOwnership(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) ep := seedSignedEntrypoint( t, db, wh.ID, database.SignatureSchemeGitLab, inboundSecret, ) stranger := authenticatedCookies( t, sess, "someone-else", "someoneelse", ) w := submitEntrypointSecret( t, h, stranger, wh.ID, ep.ID, "github", "hijacked", ) assert.Equal(t, http.StatusNotFound, w.Code) assert.Equal( t, inboundSecret, reloadEntrypoint(t, db, ep.ID).SignatureSecret, ) } // TestHandleSourceDetail_MasksEntrypointSecret is the regression test // for the credential on the entrypoint: the page has to say that // verification is configured and which header carries it, without the // secret itself ever reaching the rendered HTML. func TestHandleSourceDetail_MasksEntrypointSecret(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) seedSignedEntrypoint( t, db, wh.ID, database.SignatureSchemeGitHub, inboundSecret, ) body := renderSourceDetailPage(t, h, sess, wh.ID) assert.NotContains(t, body, inboundSecret) assert.Contains(t, body, "GitHub") assert.Contains(t, body, "X-Hub-Signature-256") } // TestEntrypointViewsDropTheSecret pins the projection itself, so the // barrier survives a template rewrite that stops rendering the field // the page test above looks at. func TestEntrypointViewsDropTheSecret(t *testing.T) { t.Parallel() views := handlers.NewEntrypointViews([]database.Entrypoint{ { Path: "p1", Active: true, SignatureScheme: database.SignatureSchemeGitHub, SignatureSecret: inboundSecret, }, { Path: "p2", }, { // A scheme this build does not know: described as // unavailable, never echoed back. Path: "p3", SignatureScheme: database.SignatureScheme("stripe"), SignatureSecret: inboundSecret, }, }) require.Len(t, views, 3) assert.True(t, views[0].Configured) assert.Equal(t, "GitHub", views[0].SchemeLabel) assert.Equal(t, "X-Hub-Signature-256", views[0].SchemeHeader) assert.False(t, views[1].Configured) assert.Equal(t, "not verified", views[1].SchemeLabel) assert.Empty(t, views[1].SchemeHeader) assert.True(t, views[2].Configured) assert.Equal(t, "(unavailable)", views[2].SchemeLabel) // The struct has no field that could carry the secret, so this // fails to compile rather than fails at runtime if one is added // and populated. The assertion covers the labels it derives. for _, v := range views { assert.NotContains(t, v.SchemeLabel, inboundSecret) assert.NotContains(t, v.SchemeHeader, inboundSecret) assert.NotContains(t, string(v.Scheme), inboundSecret) } }