check / check (push) Waiting to run
Every page's Content-Security-Policy forbids eval, which the standard Alpine.js build needs, so no directive ran in a browser: both add forms on the webhook page showed open, and events in the event log could not be collapsed. The UI now loads Alpine's CSP build (@alpinejs/csp 3.14.9 in 3p/); the policy is unchanged. Each directive names a property or method of a component registered in static/js/app.js (collapsible, targetForm), and each card holds its own x-data. A browser test, built only with the browser tag, loads the webhook page and the event log under the real headers; make test-browser runs it in Docker. New test-only dependency chromedp, which raises golang.org/x/sys to 0.47.0. Model: opus-5-5
392 lines
11 KiB
Go
392 lines
11 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/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 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)
|
|
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")
|
|
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.
|
|
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",
|
|
)
|
|
}
|
|
|
|
// 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")
|
|
}
|