Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed344ecc66 | ||
|
|
ea8384f4a2 | ||
|
|
17e6c85dd8 |
@@ -1367,17 +1367,18 @@ an entrypoint's Edit button shows its edit form in place of its description and
|
||||
hides until the form closes, Cancel hides the form and drops what was typed, as
|
||||
does leaving the page and going back to it, and Save changes the description;
|
||||
of the recent events on the webhook page only the newest starts expanded, each
|
||||
expands and collapses, and Open leads to the event's own page; an event in the
|
||||
event log expands and collapses when its row's caret or its ID is clicked, and
|
||||
from the keyboard, but not when its ID is selected with the mouse, and a
|
||||
delivery's attempts inside it expand and collapse; and at phone width the menu
|
||||
button opens and closes the mobile menu. It also fails if the browser reports a
|
||||
console warning or error, an uncaught exception, or anything the policy refused.
|
||||
`make check` and the image build lint it but do not run it, and `make test`
|
||||
leaves it out (its file is built only with the `browser` build tag). Run it with
|
||||
`make test-browser` after changing `templates/` or `static/js/`: that builds
|
||||
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser
|
||||
image, so the host needs no browser.
|
||||
expands and collapses, and Open leads to the event's own page; of the events in
|
||||
the event log only the newest starts expanded, and an event there expands and
|
||||
collapses when its row's caret or its ID is clicked, and from the keyboard, but
|
||||
not when its ID is selected with the mouse, and a delivery's attempts inside it
|
||||
expand and collapse; and at phone width the menu button opens and closes the
|
||||
mobile menu. It also fails if the browser reports a console warning or error, an
|
||||
uncaught exception, or anything the policy refused. `make check` and the image
|
||||
build lint it but do not run it, and `make test` leaves it out (its file is
|
||||
built only with the `browser` build tag). Run it with `make test-browser` after
|
||||
changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which
|
||||
runs the test in a digest-pinned headless browser image, so the host needs no
|
||||
browser.
|
||||
|
||||
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
|
||||
byte as the npm registry publishes it. It is a dependency, not this repo's build
|
||||
@@ -2578,8 +2579,9 @@ The query string is never logged; it is replaced by the fixed marker
|
||||
`/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate
|
||||
limiter in front of them, so a query on a fixed 200 URL would otherwise
|
||||
buy the same amplification as an invented path. Nothing debuggable is
|
||||
lost: `page`, on the authenticated pagination links, is the only query
|
||||
parameter this service reads.
|
||||
lost: the only query parameters this service reads are the login page's
|
||||
`next`, the page to return to, and `notice`, which names the line a page
|
||||
shows after an action.
|
||||
|
||||
Client-supplied request content does not leave the host by the other
|
||||
route either. The Sentry SDK attaches the request to every event it
|
||||
|
||||
@@ -446,9 +446,14 @@ func TestArchiveExport_OneFileOpenAtATime(t *testing.T) {
|
||||
// The writer's own handle on the last file is not the export's.
|
||||
w.Evict()
|
||||
|
||||
// Through a buffer, the open files are listed once per 8 KiB of
|
||||
// output, a few times for each file, rather than at each of gzip's
|
||||
// small writes, which takes far longer.
|
||||
peak := &openFilesPeak{dir: dir}
|
||||
buffered := bufio.NewWriterSize(peak, 8<<10)
|
||||
|
||||
require.NoError(t, writeExportTo(t, listExport(t, path), peak))
|
||||
require.NoError(t, writeExportTo(t, listExport(t, path), buffered))
|
||||
require.NoError(t, buffered.Flush())
|
||||
assert.Equal(t, 1, peak.max)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"gorm.io/gorm"
|
||||
@@ -329,24 +328,15 @@ func replayBody(body string) *string {
|
||||
}
|
||||
|
||||
// redirectToEventLog redirects a replay or resubmit back to the event
|
||||
// log it was triggered from, carrying the outcome as its notice and
|
||||
// the page number the form submitted.
|
||||
// log it was triggered from, carrying the outcome as its notice.
|
||||
func redirectToEventLog(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
code noticeCode,
|
||||
) {
|
||||
dest := withNotice("/hook/"+webhook.ID+"/events", code)
|
||||
|
||||
// The page is read from the form rather than the query string:
|
||||
// this is a POST, and its query string is what logs and Referer
|
||||
// headers record.
|
||||
if page := pageOrFirst(
|
||||
r.PostFormValue("page"),
|
||||
); page > 1 {
|
||||
dest += "&page=" + strconv.Itoa(page)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
http.Redirect(
|
||||
w, r, withNotice("/hook/"+webhook.ID+"/events", code),
|
||||
http.StatusSeeOther,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -435,9 +435,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) {
|
||||
}).Error)
|
||||
}
|
||||
|
||||
views := h.LoadEventLogViewsForTest(
|
||||
httptest.NewRecorder(), *wh, 1,
|
||||
)
|
||||
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||
require.Len(t, views, 1)
|
||||
require.Len(t, views[0].Deliveries, 1)
|
||||
|
||||
@@ -489,9 +487,7 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) {
|
||||
stored := strings.Repeat("A", responseCap*4) + tail
|
||||
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
|
||||
|
||||
views := h.LoadEventLogViewsForTest(
|
||||
httptest.NewRecorder(), *wh, 1,
|
||||
)
|
||||
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||
require.Len(t, views, 1)
|
||||
require.Len(t, views[0].Deliveries, 1)
|
||||
require.Len(t, views[0].Deliveries[0].Results, 1)
|
||||
|
||||
@@ -75,9 +75,7 @@ func seedAndProject(
|
||||
wh := seedWebhook(t, db)
|
||||
seedEventWithBody(t, dbMgr, wh.ID, body)
|
||||
|
||||
views := h.LoadEventLogViewsForTest(
|
||||
httptest.NewRecorder(), *wh, 1,
|
||||
)
|
||||
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||
require.Len(t, views, 1)
|
||||
|
||||
return views[0]
|
||||
|
||||
@@ -51,12 +51,6 @@ const (
|
||||
SidecarLeftMsgForTest = sidecarLeftMsg
|
||||
)
|
||||
|
||||
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
|
||||
// package.
|
||||
func PageOrFirstForTest(s string) int {
|
||||
return pageOrFirst(s)
|
||||
}
|
||||
|
||||
// DummyVerificationsForTest reports how many equivalent-cost
|
||||
// verifications were charged for usernames that do not exist. It
|
||||
// lets a test prove the anti-enumeration path ran without timing
|
||||
@@ -79,10 +73,9 @@ func TrimPartialRuneForTest(b []byte) []byte {
|
||||
func (s *Handlers) LoadEventLogViewsForTest(
|
||||
w http.ResponseWriter,
|
||||
webhook database.Webhook,
|
||||
page int,
|
||||
) []EventLogView {
|
||||
views, _, _ := s.loadEventsWithDeliveries(
|
||||
w, newRequestForTest(), webhook, nil, page,
|
||||
w, newRequestForTest(), webhook, nil,
|
||||
)
|
||||
|
||||
return views
|
||||
|
||||
@@ -30,10 +30,9 @@ import (
|
||||
const (
|
||||
// maxBodyShift is the bit shift for 1 MB body limit.
|
||||
maxBodyShift = 20
|
||||
// recentEventLimit is the number of recent events to show.
|
||||
// recentEventLimit is the number of most recent events that a
|
||||
// webhook's page and its event log show.
|
||||
recentEventLimit = 50
|
||||
// paginationPerPage is the number of items per page.
|
||||
paginationPerPage = 25
|
||||
|
||||
// tmplKeyError is the template data key for an error message.
|
||||
tmplKeyError = "Error"
|
||||
|
||||
@@ -57,7 +57,11 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"source_logs.html",
|
||||
map[string]any{dataKeyWebhook: webhook, "TotalEvents": int64(0)},
|
||||
map[string]any{
|
||||
dataKeyWebhook: webhook,
|
||||
dataKeyEvents: []handlers.EventLogView{},
|
||||
"TotalEvents": int64(0),
|
||||
},
|
||||
"Full Event Log - orders - Webhooker",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -275,3 +275,99 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
|
||||
`<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"?")
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -153,3 +156,55 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) {
|
||||
assert.Contains(t, body, tgt.Name)
|
||||
assert.Contains(t, body, "delivered")
|
||||
}
|
||||
|
||||
// TestHandleSourceLogs_ShowsFiftyNewestEvents proves the event log
|
||||
// holds the 50 newest events, newest first, and not one more, and says
|
||||
// how many events there are in all.
|
||||
func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
base := time.Now().Add(-time.Hour)
|
||||
|
||||
for i := range 51 {
|
||||
f.event(
|
||||
t, fmt.Sprintf("application/x-log-%02d", i), "{}",
|
||||
base.Add(time.Duration(i)*time.Second),
|
||||
)
|
||||
}
|
||||
|
||||
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||
|
||||
assert.Equal(t, 50, strings.Count(body, `role="button"`))
|
||||
assert.NotContains(t, body, "application/x-log-00")
|
||||
assert.Contains(t, body, "application/x-log-01")
|
||||
assert.Less(
|
||||
t,
|
||||
strings.Index(body, "application/x-log-50"),
|
||||
strings.Index(body, "application/x-log-49"),
|
||||
)
|
||||
assert.Contains(t, body, "50 most recent of 51 events")
|
||||
}
|
||||
|
||||
// TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the
|
||||
// events in the log only the newest starts expanded.
|
||||
func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newRecentEventsFixture(t)
|
||||
now := time.Now()
|
||||
|
||||
f.event(t, "application/x-older", "{}", now.Add(-time.Minute))
|
||||
f.event(t, "application/x-newer", "{}", now)
|
||||
|
||||
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||
|
||||
assert.Equal(t, 1, strings.Count(body, " data-open>"))
|
||||
|
||||
open := strings.Index(body, " data-open>")
|
||||
newer := strings.Index(body, "application/x-newer")
|
||||
older := strings.Index(body, "application/x-older")
|
||||
|
||||
assert.Less(t, open, newer, "the newest event is not the open one")
|
||||
assert.Less(t, newer, older)
|
||||
}
|
||||
|
||||
@@ -618,8 +618,9 @@ func (h *Handlers) renderSourceDetail(
|
||||
|
||||
// The host is the client's Host header, unvalidated. It is
|
||||
// inert only because source_detail.html renders BaseURL as
|
||||
// text inside a <code> element; putting it in an href or any
|
||||
// other URL context needs it constrained first.
|
||||
// text, inside a <code> element and in an entrypoint's delete
|
||||
// prompt; putting it in an href or any other URL context
|
||||
// needs it constrained first.
|
||||
baseURL := scheme + "://" + r.Host
|
||||
|
||||
// The template calls Webhook methods, which take pointer
|
||||
@@ -1119,30 +1120,17 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
page := h.parsePage(r)
|
||||
|
||||
evts, total, ok := h.loadEventsWithDeliveries(
|
||||
w, r, webhook, targets, page,
|
||||
w, r, webhook, targets,
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := int(total) / paginationPerPage
|
||||
if int(total)%paginationPerPage != 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
tmplKeyWebhook: &webhook,
|
||||
"Events": evts,
|
||||
"Page": page,
|
||||
"TotalPages": totalPages,
|
||||
"TotalEvents": total,
|
||||
"HasPrev": page > 1,
|
||||
"HasNext": page < totalPages,
|
||||
"PrevPage": page - 1,
|
||||
"NextPage": page + 1,
|
||||
}
|
||||
|
||||
h.renderTemplate(w, r, "source_logs.html", data)
|
||||
@@ -1202,15 +1190,11 @@ func (h *Handlers) loadTargetMap(
|
||||
return targetMap, nil
|
||||
}
|
||||
|
||||
// parsePage extracts a page number from the query string.
|
||||
func (h *Handlers) parsePage(r *http.Request) int {
|
||||
return pageOrFirst(r.URL.Query().Get("page"))
|
||||
}
|
||||
|
||||
// loadEventsWithDeliveries loads paginated events and their
|
||||
// deliveries from the per-webhook database. Events come back
|
||||
// as capped projections rather than database.Event rows: see
|
||||
// eventLogColumns for why the cut happens in SQL.
|
||||
// loadEventsWithDeliveries loads the recentEventLimit newest events
|
||||
// and their deliveries from the per-webhook database, and the total
|
||||
// number of events stored. Events come back as capped projections
|
||||
// rather than database.Event rows: see eventLogColumns for why the
|
||||
// cut happens in SQL.
|
||||
//
|
||||
// The bool reports whether the load succeeded. It is false
|
||||
// once this has answered the request with an error, and the
|
||||
@@ -1220,7 +1204,6 @@ func (h *Handlers) loadEventsWithDeliveries(
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
targetMap map[string]eventLogTarget,
|
||||
page int,
|
||||
) ([]EventLogView, int64, bool) {
|
||||
if !h.dbMgr.DBExists(webhook.ID) {
|
||||
return nil, 0, true
|
||||
@@ -1235,9 +1218,7 @@ func (h *Handlers) loadEventsWithDeliveries(
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
rows, totalEvents := loadEventLogRows(
|
||||
webhookDB, webhook.ID, page,
|
||||
)
|
||||
rows, totalEvents := loadEventLogRows(webhookDB, webhook.ID)
|
||||
|
||||
result, ok := h.eventLogViews(
|
||||
w, r, webhookDB, webhook.ID, rows, targetMap,
|
||||
@@ -1310,10 +1291,11 @@ func (h *Handlers) eventLogViews(
|
||||
return result, true
|
||||
}
|
||||
|
||||
// loadEventLogRows reads one page of the event log projection, newest
|
||||
// first, and the total number of events the pager counts against.
|
||||
// loadEventLogRows reads the event log projection of the
|
||||
// recentEventLimit newest events, newest first, and the total number
|
||||
// of events stored.
|
||||
func loadEventLogRows(
|
||||
webhookDB *gorm.DB, webhookID string, page int,
|
||||
webhookDB *gorm.DB, webhookID string,
|
||||
) ([]eventLogRow, int64) {
|
||||
var totalEvents int64
|
||||
|
||||
@@ -1327,9 +1309,7 @@ func loadEventLogRows(
|
||||
eventLogColumns, maxRenderedBodyBytes,
|
||||
).Where(
|
||||
"webhook_id = ?", webhookID,
|
||||
).Order("created_at DESC").Offset(
|
||||
(page - 1) * paginationPerPage,
|
||||
).Limit(paginationPerPage).Find(&rows)
|
||||
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows)
|
||||
|
||||
return rows, totalEvents
|
||||
}
|
||||
@@ -1338,9 +1318,9 @@ func loadEventLogRows(
|
||||
// events have been resubmitted from it.
|
||||
//
|
||||
// One grouped query covers the page rather than one query per event.
|
||||
// A page holds paginationPerPage ids, far below SQLite's bound
|
||||
// parameter ceiling, so it needs no chunking as the delivery result
|
||||
// load does.
|
||||
// The page shows at most recentEventLimit events, far below SQLite's
|
||||
// bound parameter ceiling, so it needs no chunking as the delivery
|
||||
// result load does.
|
||||
func resubmitCounts(
|
||||
webhookDB *gorm.DB, eventIDs []string,
|
||||
) (map[string]int, error) {
|
||||
@@ -1766,24 +1746,6 @@ func (h *Handlers) setTargetFromForm(
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// pageOrFirst parses a paginated page number, answering 1 for
|
||||
// anything empty, unparseable or out of range.
|
||||
//
|
||||
// Falling back rather than rejecting is correct here and only here:
|
||||
// a page number is where to send the browser next, not configuration
|
||||
// the operator is storing, and the actions that submit one have
|
||||
// already completed by the time it is read — answering 400 would
|
||||
// report a failure that did not happen. Anything an operator SETS
|
||||
// must be validated instead; see parseMaxRetries.
|
||||
func pageOrFirst(s string) int {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || v < 1 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// targetFormInput carries the raw values of a target form. Both the
|
||||
// create and the edit path fill one and hand it to setTargetFromForm,
|
||||
// so neither can come to validate a target differently from the
|
||||
|
||||
@@ -383,20 +383,3 @@ func TestTargetRetries_CreateAndEditAgreeOnEveryCase(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPageOrFirst_CoercesRatherThanRejects pins the one place a
|
||||
// non-numeric form value legitimately falls back. A page number says
|
||||
// where to send the browser after an action that has already
|
||||
// happened, so it is not configuration and rejecting it would report
|
||||
// a failure that did not occur.
|
||||
func TestPageOrFirst_CoercesRatherThanRejects(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, s := range []string{"", "abc", "0", "-1", "2.7", " "} {
|
||||
assert.Equal(t, 1, handlers.PageOrFirstForTest(s),
|
||||
"%q should fall back to the first page", s)
|
||||
}
|
||||
|
||||
assert.Equal(t, 4, handlers.PageOrFirstForTest("4"))
|
||||
assert.Equal(t, 4, handlers.PageOrFirstForTest(" 4 "))
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
const (
|
||||
dataKeyWebhook = "Webhook"
|
||||
dataKeyError = "Error"
|
||||
dataKeyEvents = "Events"
|
||||
)
|
||||
|
||||
// testWebhookID is the identifier given to the webhook under test on
|
||||
@@ -144,9 +145,10 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
// A pointer, as in the handlers: source_detail.html calls
|
||||
// Webhook.RetentionLabel, a pointer method. Both pages only range
|
||||
// over their lists, and a list left out renders as empty, so the
|
||||
// lists are left out.
|
||||
// Webhook.RetentionLabel, a pointer method. The webhook page only
|
||||
// ranges over its lists, and a list left out renders as empty, so
|
||||
// its lists are left out. The event log also counts its events, so
|
||||
// it gets an empty list.
|
||||
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
|
||||
webhook.ID = testWebhookID
|
||||
|
||||
@@ -169,6 +171,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
|
||||
|
||||
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
|
||||
dataKeyWebhook: webhook,
|
||||
dataKeyEvents: []handlers.EventLogView{},
|
||||
"TotalEvents": int64(0),
|
||||
})
|
||||
|
||||
@@ -317,9 +320,9 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
||||
"Entrypoints": handlers.NewEntrypointViews(
|
||||
[]database.Entrypoint{entrypoint},
|
||||
),
|
||||
"Targets": delivery.NewTargetViews(nil),
|
||||
"Events": []database.Event{},
|
||||
"BaseURL": "https://hooks.example.com",
|
||||
"Targets": delivery.NewTargetViews(nil),
|
||||
dataKeyEvents: []database.Event{},
|
||||
"BaseURL": "https://hooks.example.com",
|
||||
})
|
||||
|
||||
assert.Contains(
|
||||
@@ -384,9 +387,9 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
||||
"Entrypoints": handlers.NewEntrypointViews(
|
||||
[]database.Entrypoint{entrypoint},
|
||||
),
|
||||
"Targets": delivery.NewTargetViews(nil),
|
||||
"Events": []database.Event{},
|
||||
"BaseURL": "https://hooks.example.com",
|
||||
"Targets": delivery.NewTargetViews(nil),
|
||||
dataKeyEvents: []database.Event{},
|
||||
"BaseURL": "https://hooks.example.com",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -278,12 +278,13 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
||||
// after the '?'. Keeping the path and dropping the query is what makes
|
||||
// this branch as bounded as the pattern branches below.
|
||||
//
|
||||
// Nothing debuggable is lost. One route in the service reads a query
|
||||
// parameter at all — `page`, on the authenticated pagination links in
|
||||
// internal/handlers/source_management.go — and the alternatives that
|
||||
// would preserve more (a key count, a key allowlist) all require
|
||||
// parsing an attacker-sized query on every request, which is work an
|
||||
// unauthenticated client would then be choosing for us.
|
||||
// Nothing debuggable is lost. The only query parameters the service
|
||||
// reads are the login page's `next`, the page to return to, and
|
||||
// `notice`, which names the line a page shows after an action. The
|
||||
// alternatives that would preserve more (a key count, a key
|
||||
// allowlist) all require parsing an attacker-sized query on every
|
||||
// request, which is work an unauthenticated client would then be
|
||||
// choosing for us.
|
||||
func concreteLogURL(r *http.Request) string {
|
||||
path := r.URL.EscapedPath()
|
||||
|
||||
|
||||
@@ -779,14 +779,15 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
|
||||
"the event's own page does not show its body")
|
||||
}
|
||||
|
||||
// checkEventLog loads the event log and checks an event's row. Clicking
|
||||
// its ID expands the event, and in there clicking its delivery shows the
|
||||
// delivery's attempts and clicking again hides them. Clicking the row's
|
||||
// caret collapses the event, clicking it again expands it, and clicking
|
||||
// the ID again collapses it. While the event is expanded the row says so
|
||||
// and its caret is turned up, and while it is collapsed neither. It then
|
||||
// runs checkEventSelection on the log's last event, lastEventID, and
|
||||
// checkEventKeyboard on eventID.
|
||||
// checkEventLog loads the event log and checks that of its events only
|
||||
// the newest, eventID, starts expanded: its row says so and its caret is
|
||||
// turned up, and the log's last event, lastEventID, starts collapsed. In
|
||||
// the newest event, clicking its delivery shows the delivery's attempts
|
||||
// and clicking again hides them. Clicking the row's caret collapses the
|
||||
// event, clicking it again expands it, clicking its ID collapses it and
|
||||
// clicking the ID again expands it. While the event is collapsed the row
|
||||
// says so and its caret is turned down. It then runs checkEventSelection
|
||||
// on lastEventID and checkEventKeyboard on eventID.
|
||||
func checkEventLog(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
@@ -804,20 +805,19 @@ func checkEventLog(
|
||||
caretUp := caret + `[contains(@class, "rotate-180")]`
|
||||
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
|
||||
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
||||
lastExpanded := `form[action$="/` + lastEventID + `/resubmit"]`
|
||||
deliveryRow := `//span[text()="` + targetName + `"]`
|
||||
attempt := `//span[text()="Attempt 1"]`
|
||||
|
||||
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
||||
|
||||
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
|
||||
|
||||
click(ctx, t, id)
|
||||
assert.True(t, shown(ctx, expanded),
|
||||
"clicking the event's ID does not expand it")
|
||||
assert.True(t, shown(ctx, expanded), "the newest event starts collapsed")
|
||||
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
|
||||
"the expanded event's row does not say it is expanded")
|
||||
assert.True(t, shown(ctx, caretUp),
|
||||
"the expanded event's caret does not turn up")
|
||||
assert.True(t, hidden(ctx, lastExpanded),
|
||||
"an older event starts expanded")
|
||||
|
||||
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
||||
|
||||
@@ -843,7 +843,11 @@ func checkEventLog(
|
||||
|
||||
click(ctx, t, id)
|
||||
assert.True(t, hidden(ctx, expanded),
|
||||
"clicking the event's ID again does not collapse it")
|
||||
"clicking the event's ID does not collapse it")
|
||||
|
||||
click(ctx, t, id)
|
||||
assert.True(t, shown(ctx, expanded),
|
||||
"clicking the event's ID again does not expand it")
|
||||
|
||||
checkEventSelection(ctx, t, url, lastEventID)
|
||||
checkEventKeyboard(ctx, t, url, eventID)
|
||||
@@ -892,8 +896,11 @@ func checkEventSelection(
|
||||
`window.getSelection().removeAllRanges()`, nil,
|
||||
)
|
||||
|
||||
// The newest event starts expanded, which can push this one below
|
||||
// the short window, where the mouse cannot reach it.
|
||||
require.NoError(t, chromedp.Run(
|
||||
ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url),
|
||||
chromedp.ScrollIntoView(id, chromedp.BySearch),
|
||||
))
|
||||
|
||||
selectText(ctx, t, id)
|
||||
@@ -936,8 +943,9 @@ func checkEventSelection(
|
||||
}
|
||||
|
||||
// checkEventKeyboard loads the event log and checks that Tab from the
|
||||
// page's Back link reaches the event's row, the first after it, and that
|
||||
// Enter then expands the event and Space collapses it.
|
||||
// page's Back link reaches the row of the newest event, the first after
|
||||
// it, and that Enter then collapses that event, which starts expanded,
|
||||
// and Space expands it again.
|
||||
func checkEventKeyboard(
|
||||
ctx context.Context, t *testing.T, url, eventID string,
|
||||
) {
|
||||
@@ -959,10 +967,10 @@ func checkEventKeyboard(
|
||||
"Tab from the Back link does not reach the event's row")
|
||||
|
||||
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
|
||||
assert.True(t, shown(ctx, expanded), "Enter does not expand the event")
|
||||
assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event")
|
||||
|
||||
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
|
||||
assert.True(t, hidden(ctx, expanded), "Space does not collapse the event")
|
||||
assert.True(t, shown(ctx, expanded), "Space does not expand the event")
|
||||
}
|
||||
|
||||
// selectText selects the text of the element matching an XPath
|
||||
|
||||
@@ -1379,9 +1379,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
|
||||
|
||||
// TestHook_LinksBetweenPages follows each link to a webhook page that
|
||||
// the tests above do not: the navbar's "Webhooks" links, the back and
|
||||
// Cancel links, the list's link to a webhook, the "Full Event Log"
|
||||
// link beside the recent events, and the event log's page links. Each
|
||||
// must point where it should, and that page must render.
|
||||
// Cancel links, the list's link to a webhook, and the "Full Event Log"
|
||||
// link beside the recent events. Each must point where it should, and
|
||||
// that page must render.
|
||||
func TestHook_LinksBetweenPages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1392,12 +1392,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
|
||||
wh := env.seedWebhook(t, userID)
|
||||
tgt := env.seedTarget(t, wh.ID)
|
||||
|
||||
// The event log shows 25 events a page; one more gives it a second
|
||||
// page, so it renders its Next and Previous links.
|
||||
for range 26 {
|
||||
env.seedEvent(t, wh.ID, "paged")
|
||||
}
|
||||
|
||||
list := "/hooks"
|
||||
newForm := list + "/new"
|
||||
page := "/hook/" + wh.ID
|
||||
@@ -1429,8 +1423,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
|
||||
{targetEdit, back, page},
|
||||
{targetEdit, cancel, page},
|
||||
{events, back, page},
|
||||
{events, `href="([^"]+)"[^>]*>Next →<`, events + "?page=2"},
|
||||
{events + "?page=2", `href="([^"]+)"[^>]*>← Previous<`, events + "?page=1"},
|
||||
} {
|
||||
got := env.urlFrom(t, link.from, link.pattern, cookies)
|
||||
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
|
||||
|
||||
@@ -218,8 +218,9 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
|
||||
|
||||
// sentryKeepsHeader reports whether a request header is routing or
|
||||
// content metadata rather than client-chosen payload. Referer is kept
|
||||
// on the reasoning that it is browser-set, that this service emits
|
||||
// only ?page= in its own links, and that Referrer-Policy is set to
|
||||
// on the reasoning that it is browser-set, that the only query
|
||||
// parameters in this service's own URLs are the login page's `next`
|
||||
// and `notice`, and that Referrer-Policy is set to
|
||||
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
|
||||
// local access log line, which holds the rest of the detail.
|
||||
func sentryKeepsHeader(name string) bool {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -20,7 +20,11 @@
|
||||
<div class="flex gap-2">
|
||||
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
|
||||
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
||||
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
||||
<!-- The delete prompts are the browser's own, so they
|
||||
work without the page's scripts. The template
|
||||
escapes each name for the script, so a quote or a
|
||||
backslash in it shows as typed. -->
|
||||
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete webhook "{{.Webhook.Name}}"?\n\nThis deletes its stored events{{with .Stats}} ({{.WithinRetention.Events}}){{end}} and their deliveries. Any archive files it wrote are kept.')">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn-danger">Delete</button>
|
||||
</form>
|
||||
@@ -83,7 +87,7 @@
|
||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete entrypoint "{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}"?\n\nSenders using its URL get an error from now on, and the URL cannot be restored.')" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
||||
</form>
|
||||
@@ -263,7 +267,7 @@
|
||||
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete target "{{.Name}}"?\n\nNothing more is delivered to it. Its past deliveries stay in the event log.')" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
|
||||
</form>
|
||||
|
||||
@@ -8,14 +8,15 @@
|
||||
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← Back to {{.Webhook.Name}}</a>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
|
||||
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
|
||||
<span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="divide-y divide-gray-100">
|
||||
{{range .Events}}
|
||||
<div class="p-4" x-data="collapsible">
|
||||
<!-- Only the newest event starts expanded. -->
|
||||
{{range $i, $event := .Events}}
|
||||
<div class="p-4" x-data="collapsible"{{if eq $i 0}} data-open{{end}}>
|
||||
<!-- Not a button element: browsers do not let a button's text be selected, and an event's ID must be. -->
|
||||
<div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @mousedown="cancelPendingToggle" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
|
||||
<span class="flex flex-wrap items-center gap-3">
|
||||
@@ -51,7 +52,6 @@
|
||||
</div>
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="page" value="{{$.Page}}">
|
||||
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -79,7 +79,6 @@
|
||||
{{if and .Status.Terminal (not .Target.Deleted)}}
|
||||
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="page" value="{{$.Page}}">
|
||||
<button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -100,18 +99,5 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{{if or .HasPrev .HasNext}}
|
||||
<div class="flex justify-center gap-2 mt-6">
|
||||
{{if .HasPrev}}
|
||||
<a href="/hook/{{.Webhook.ID}}/events?page={{.PrevPage}}" class="btn-secondary text-sm">← Previous</a>
|
||||
{{end}}
|
||||
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
||||
{{if .HasNext}}
|
||||
<a href="/hook/{{.Webhook.ID}}/events?page={{.NextPage}}" class="btn-secondary text-sm">Next →</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user