check / check (push) Waiting to run
An entrypoint's description was set when it was added and could never change, so renaming one meant deleting it and adding a new one with a new URL every sender had to be given again. Each entrypoint on the webhook page now has an Edit button, in the shared secondary style, that opens its description in place with Save and Cancel and keeps its URL. The save goes through the same login, CSRF and ownership checks as the other entrypoint actions; an empty description shows as "Entrypoint". Activate and deactivate now write only the active column, so they cannot undo an edit. Tests cover each, through the router and the browser. Model: opus-5-5
512 lines
15 KiB
Go
512 lines
15 KiB
Go
//go:build browser
|
|
|
|
// This test needs a headless browser, so it is built only with the
|
|
// browser build tag: `make test` leaves it out, and `make test-browser`
|
|
// runs it in the browser image that Dockerfile.browser pins.
|
|
|
|
package server_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/chromedp/cdproto/browser"
|
|
"github.com/chromedp/cdproto/log"
|
|
"github.com/chromedp/cdproto/network"
|
|
"github.com/chromedp/cdproto/runtime"
|
|
"github.com/chromedp/chromedp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm/clause"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
const (
|
|
// browserTimeout bounds everything one test does in the browser.
|
|
browserTimeout = 60 * time.Second
|
|
|
|
// settleTimeout bounds the wait for an element to show or hide.
|
|
settleTimeout = 5 * time.Second
|
|
|
|
// The window size of a phone, narrow enough that the pages show
|
|
// the mobile menu button instead of the navigation links.
|
|
phoneWidth = 390
|
|
phoneHeight = 844
|
|
)
|
|
|
|
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
|
|
// event log in a headless browser, served by the real router and so
|
|
// under the real Content-Security-Policy, and checks that the pages'
|
|
// Alpine.js directives and the copy control work.
|
|
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, problems := startBrowser(t)
|
|
|
|
env := newTestEnv(t)
|
|
srv := httptest.NewServer(env.router)
|
|
t.Cleanup(srv.Close)
|
|
|
|
userID, _ := env.seedUser(t, "browser", "browser-password")
|
|
webhook := env.seedWebhook(t, userID)
|
|
require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
|
|
&database.Entrypoint{
|
|
WebhookID: webhook.ID,
|
|
Path: "3c9e1f7a-5b2d-4e8a-9f6c-2a7d1e4b8c05",
|
|
Active: true,
|
|
},
|
|
).Error)
|
|
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
|
|
target := env.seedTarget(t, webhook.ID)
|
|
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
|
|
|
|
webhookDB, err := env.dbMgr.GetDB(webhook.ID)
|
|
require.NoError(t, err)
|
|
require.NoError(t, webhookDB.Omit(clause.Associations).Create(
|
|
&database.DeliveryResult{
|
|
DeliveryID: dlv.ID,
|
|
AttemptNum: 1,
|
|
StatusCode: http.StatusBadGateway,
|
|
},
|
|
).Error)
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
|
))
|
|
|
|
page := srv.URL + "/hook/" + webhook.ID
|
|
|
|
checkAddForms(ctx, t, page)
|
|
checkTargetType(ctx, t, page+"/events")
|
|
checkCopy(ctx, t, page)
|
|
checkEntrypointEdit(ctx, t, page, page+"/events")
|
|
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
|
checkMobileMenu(ctx, t, page)
|
|
|
|
assert.Empty(t, problems(), "the browser reported problems")
|
|
}
|
|
|
|
// startBrowser starts a headless browser for one test. It returns the
|
|
// context that drives it, and a function listing what the browser
|
|
// reported going wrong on its pages: console warnings and errors,
|
|
// which is how Alpine.js reports an expression it cannot run; uncaught
|
|
// exceptions; and every entry in the browser's own security log, which
|
|
// is where it reports each script, style, image or request the
|
|
// Content-Security-Policy refused.
|
|
//
|
|
// The browser library finds the browser on PATH. Without one the first
|
|
// chromedp.Run fails, and with it the test.
|
|
func startBrowser(t *testing.T) (context.Context, func() []string) {
|
|
t.Helper()
|
|
|
|
allocCtx, cancelAlloc := chromedp.NewExecAllocator(
|
|
t.Context(),
|
|
append(
|
|
chromedp.DefaultExecAllocatorOptions[:],
|
|
// Dockerfile.browser runs the test as root, where the
|
|
// browser's sandbox cannot start.
|
|
chromedp.NoSandbox,
|
|
)...,
|
|
)
|
|
t.Cleanup(cancelAlloc)
|
|
|
|
ctx, cancel := chromedp.NewContext(allocCtx)
|
|
t.Cleanup(cancel)
|
|
|
|
ctx, cancelTimeout := context.WithTimeout(ctx, browserTimeout)
|
|
t.Cleanup(cancelTimeout)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
problems []string
|
|
)
|
|
|
|
chromedp.ListenTarget(ctx, func(ev any) {
|
|
var problem string
|
|
|
|
switch ev := ev.(type) {
|
|
case *runtime.EventConsoleAPICalled:
|
|
if ev.Type != runtime.APITypeWarning &&
|
|
ev.Type != runtime.APITypeError {
|
|
return
|
|
}
|
|
|
|
args := make([]string, 0, len(ev.Args))
|
|
for _, arg := range ev.Args {
|
|
args = append(args, string(arg.Value))
|
|
}
|
|
|
|
problem = strings.Join(args, " ")
|
|
case *runtime.EventExceptionThrown:
|
|
problem = ev.ExceptionDetails.Error()
|
|
case *log.EventEntryAdded:
|
|
if ev.Entry.Source != log.SourceSecurity {
|
|
return
|
|
}
|
|
|
|
problem = ev.Entry.Text
|
|
default:
|
|
return
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
problems = append(problems, problem)
|
|
})
|
|
|
|
return ctx, func() []string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
return slices.Clone(problems)
|
|
}
|
|
}
|
|
|
|
// setCookies gives the browser the cookies for the server at base.
|
|
func setCookies(base string, cookies []*http.Cookie) chromedp.ActionFunc {
|
|
return chromedp.ActionFunc(func(ctx context.Context) error {
|
|
for _, c := range cookies {
|
|
err := network.SetCookie(c.Name, c.Value).
|
|
WithURL(base).
|
|
Do(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("set cookie %s: %w", c.Name, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// loadPage opens url and waits for Alpine.js to start, which it does
|
|
// by removing every x-cloak attribute. Until then x-cloak hides the
|
|
// elements Alpine would hide, so a check made earlier proves nothing.
|
|
func loadPage(url string) chromedp.Tasks {
|
|
return chromedp.Tasks{
|
|
chromedp.Navigate(url),
|
|
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
|
}
|
|
}
|
|
|
|
// shown waits up to settleTimeout for the elements matching a CSS
|
|
// selector or an XPath expression to be rendered, and reports whether
|
|
// they were. The wait is needed because Alpine.js shows an element on
|
|
// the next animation frame, not at once.
|
|
func shown(ctx context.Context, selector string) bool {
|
|
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
|
defer cancel()
|
|
|
|
return chromedp.Run(
|
|
ctx, chromedp.WaitVisible(selector, chromedp.BySearch),
|
|
) == nil
|
|
}
|
|
|
|
// hidden is shown's opposite: it waits for the elements to be hidden.
|
|
func hidden(ctx context.Context, selector string) bool {
|
|
ctx, cancel := context.WithTimeout(ctx, settleTimeout)
|
|
defer cancel()
|
|
|
|
return chromedp.Run(
|
|
ctx, chromedp.WaitNotVisible(selector, chromedp.BySearch),
|
|
) == nil
|
|
}
|
|
|
|
// click clicks the element matching an XPath expression.
|
|
func click(ctx context.Context, t *testing.T, xpath string) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.Click(xpath, chromedp.BySearch),
|
|
))
|
|
}
|
|
|
|
// checkAddForms loads a webhook page and checks that each section's add
|
|
// form stays hidden until the Add button beside its heading is clicked.
|
|
// The click looks for a button element there, so it also checks that
|
|
// Add is one.
|
|
func checkAddForms(ctx context.Context, t *testing.T, url string) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
sections := []struct{ heading, form string }{
|
|
{"Entrypoints", `form[action$="/entrypoints"]`},
|
|
{"Targets", `form[action$="/targets"]`},
|
|
}
|
|
|
|
for _, s := range sections {
|
|
assert.Truef(
|
|
t, hidden(ctx, s.form),
|
|
"%s: the add form shows before Add is clicked", s.heading,
|
|
)
|
|
|
|
click(ctx, t, `//h2[text()="`+s.heading+
|
|
`"]/following-sibling::button`)
|
|
|
|
assert.Truef(
|
|
t, shown(ctx, s.form),
|
|
"%s: the add form stays hidden when Add is clicked", s.heading,
|
|
)
|
|
}
|
|
}
|
|
|
|
// checkTargetType chooses Slack in the open add target form and checks
|
|
// what the form would then submit: one url field, the Slack one, and
|
|
// not the HTTP url, headers or timeout, which are hidden and disabled.
|
|
//
|
|
// It then opens the page at elsewhere and goes back. The browser loads
|
|
// the webhook page again and restores the form as it was left, Slack
|
|
// chosen, without a change event; the form must again show and submit
|
|
// Slack's fields, not the HTTP ones.
|
|
func checkTargetType(ctx context.Context, t *testing.T, elsewhere string) {
|
|
t.Helper()
|
|
|
|
const (
|
|
chooseSlack = `(() => {
|
|
const type = document.querySelector('select[name="type"]');
|
|
type.value = "slack";
|
|
type.dispatchEvent(new Event("change"));
|
|
})()`
|
|
chosen = `document.querySelector('select[name="type"]').value`
|
|
howLoaded = `performance.getEntriesByType("navigation")[0].type`
|
|
submitted = `[...new FormData(
|
|
document.querySelector('form[action$="/targets"]')).keys()]`
|
|
slackURL = `input[placeholder^="https://hooks.slack.com/"]`
|
|
httpURL = `input[placeholder="https://example.com/webhook"]`
|
|
)
|
|
|
|
slackFields := strings.Fields("csrf_token name type max_retries url")
|
|
|
|
var fields []string
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
chromedp.Evaluate(chooseSlack, nil),
|
|
chromedp.Evaluate(submitted, &fields),
|
|
))
|
|
|
|
assert.Equal(
|
|
t, slackFields, fields,
|
|
"with Slack chosen, the HTTP fields must not be submitted",
|
|
)
|
|
|
|
var loaded, restored string
|
|
|
|
// Going back waits for the load event, after which the browser has
|
|
// restored the form.
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
loadPage(elsewhere),
|
|
chromedp.NavigateBack(),
|
|
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
|
chromedp.Evaluate(howLoaded, &loaded),
|
|
chromedp.Evaluate(chosen, &restored),
|
|
))
|
|
|
|
// A page the browser kept in memory and showed again as it was
|
|
// would prove nothing here.
|
|
require.Equal(
|
|
t, "back_forward", loaded,
|
|
"going back, the browser did not load the page again",
|
|
)
|
|
require.Equal(
|
|
t, "slack", restored,
|
|
"going back, the browser did not restore the chosen type",
|
|
)
|
|
|
|
click(ctx, t, `//h2[text()="Targets"]/following-sibling::button`)
|
|
|
|
assert.True(t, shown(ctx, slackURL),
|
|
"going back with Slack chosen, the Slack fields are not shown")
|
|
assert.True(t, hidden(ctx, httpURL),
|
|
"going back with Slack chosen, the HTTP fields are shown")
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.Evaluate(submitted, &fields),
|
|
))
|
|
|
|
assert.Equal(
|
|
t, slackFields, fields,
|
|
"going back with Slack chosen, the HTTP fields must not be submitted",
|
|
)
|
|
}
|
|
|
|
// checkCopy loads a webhook page and checks that the Copy control beside
|
|
// its entrypoint's URL is a button, and that clicking it copies the URL
|
|
// and says so: the button reads "Copied" only once the copy succeeded.
|
|
func checkCopy(ctx context.Context, t *testing.T, url string) {
|
|
t.Helper()
|
|
|
|
copyButton := `//button[@data-copy-target]`
|
|
|
|
// A browser lets the page in its active tab write to the clipboard
|
|
// on a click. A headless browser refuses unless told to allow it.
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
browser.SetPermission(
|
|
&browser.PermissionDescriptor{Name: "clipboard-write"},
|
|
browser.PermissionSettingGranted,
|
|
),
|
|
loadPage(url),
|
|
))
|
|
|
|
click(ctx, t, copyButton)
|
|
|
|
assert.True(t, shown(ctx, copyButton+`[text()="Copied"]`),
|
|
`clicking Copy does not show "Copied"`)
|
|
}
|
|
|
|
// checkEntrypointEdit loads a webhook page whose entrypoint has no
|
|
// description, and checks that Edit shows the edit form in place of
|
|
// the description and hides until the form closes, so the form always
|
|
// opens on the saved description; that Cancel hides it and drops what
|
|
// was typed; that after typing, opening the page at elsewhere and going
|
|
// back, Edit again opens the form on the saved description; and that
|
|
// Save changes the description the page shows.
|
|
func checkEntrypointEdit(
|
|
ctx context.Context, t *testing.T, url, elsewhere string,
|
|
) {
|
|
t.Helper()
|
|
|
|
const (
|
|
editForm = `form[action$="/edit"]`
|
|
input = editForm + ` input[name="description"]`
|
|
description = `//span[text()="Entrypoint"]`
|
|
edit = `//button[text()="Edit"]`
|
|
)
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
assert.True(t, hidden(ctx, editForm),
|
|
"the edit form shows before Edit is clicked")
|
|
|
|
click(ctx, t, edit)
|
|
assert.True(t, shown(ctx, editForm),
|
|
"clicking Edit does not show the edit form")
|
|
assert.True(t, hidden(ctx, description),
|
|
"the description stays shown beside the edit form")
|
|
assert.True(t, hidden(ctx, edit),
|
|
"Edit stays shown while the edit form is open")
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.SendKeys(input, "draft", chromedp.ByQuery),
|
|
))
|
|
click(ctx, t, `//button[text()="Cancel"]`)
|
|
assert.True(t, hidden(ctx, editForm),
|
|
"clicking Cancel does not hide the edit form")
|
|
assert.True(t, shown(ctx, description),
|
|
"clicking Cancel does not show the description again")
|
|
assert.True(t, shown(ctx, edit),
|
|
"clicking Cancel does not show Edit again")
|
|
|
|
var typed string
|
|
|
|
click(ctx, t, edit)
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.Value(input, &typed, chromedp.ByQuery),
|
|
))
|
|
assert.Empty(t, typed, "Cancel keeps what was typed")
|
|
|
|
var loaded string
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
chromedp.SendKeys(input, "draft", chromedp.ByQuery),
|
|
loadPage(elsewhere),
|
|
chromedp.NavigateBack(),
|
|
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
|
chromedp.Evaluate(
|
|
`performance.getEntriesByType("navigation")[0].type`, &loaded,
|
|
),
|
|
))
|
|
require.Equal(
|
|
t, "back_forward", loaded,
|
|
"going back, the browser did not load the page again",
|
|
)
|
|
|
|
click(ctx, t, edit)
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.Value(input, &typed, chromedp.ByQuery),
|
|
))
|
|
assert.Empty(t, typed, "going back puts what was typed back in the form")
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.SendKeys(input, "Billing sender", chromedp.ByQuery),
|
|
))
|
|
click(ctx, t, `//button[text()="Save"]`)
|
|
|
|
assert.True(t, shown(ctx, `//span[text()="Billing sender"]`),
|
|
"saving the edit form does not change the description")
|
|
}
|
|
|
|
// checkEventLog loads the event log and checks that clicking an event's
|
|
// row expands it, that in there clicking its delivery shows the
|
|
// delivery's attempts and clicking again hides them, and that clicking
|
|
// the event's row again collapses it.
|
|
func checkEventLog(
|
|
ctx context.Context, t *testing.T, url, eventID, targetName string,
|
|
) {
|
|
t.Helper()
|
|
|
|
// The event's row shows its ID, and its Resubmit form is in the part
|
|
// that expands. The delivery's row there shows the target's name.
|
|
eventRow := `//span[text()="` + eventID + `"]`
|
|
expanded := `form[action$="/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, eventRow)
|
|
assert.True(t, shown(ctx, expanded), "clicking the event does not expand it")
|
|
|
|
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
|
|
|
|
click(ctx, t, deliveryRow)
|
|
assert.True(t, shown(ctx, attempt),
|
|
"clicking the delivery does not show its attempts")
|
|
|
|
click(ctx, t, deliveryRow)
|
|
assert.True(t, hidden(ctx, attempt),
|
|
"clicking the delivery again does not hide its attempts")
|
|
|
|
click(ctx, t, eventRow)
|
|
assert.True(t, hidden(ctx, expanded),
|
|
"clicking the event again does not collapse it")
|
|
}
|
|
|
|
// checkMobileMenu loads a page in a phone-sized window and checks that
|
|
// the menu button opens and closes the mobile menu.
|
|
func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
|
|
t.Helper()
|
|
|
|
// The menu button is the only button directly in the navigation
|
|
// bar's top row. Profile is a link only the mobile menu has.
|
|
button := `//nav/div/button`
|
|
menu := `//nav//a[text()="Profile"]`
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
chromedp.EmulateViewport(phoneWidth, phoneHeight),
|
|
loadPage(url),
|
|
))
|
|
|
|
assert.True(t, hidden(ctx, menu), "the mobile menu starts open")
|
|
|
|
click(ctx, t, button)
|
|
assert.True(t, shown(ctx, menu), "the menu button does not open the menu")
|
|
|
|
click(ctx, t, button)
|
|
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
|
|
}
|