The webhook page's targets section lists only its targets until Add is clicked. Add shows a choice of target type with Next; Next shows only that type's fields, with Save and Cancel. The `database` and `log` types have no URL field, and the `slack` form gains max retries. A refused target now shows the webhook page again with the form open on its type, the values entered and the reason, instead of a bare text page. Target validation returns that message rather than writing the response; newTarget validates a whole new target for reuse by the new-webhook page. The edit page still answers a refusal in plain text. Model: opus-5-5
518 lines
16 KiB
Go
518 lines
16 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
|
|
|
|
checkAddEntrypoint(ctx, t, page)
|
|
|
|
// Each target type, with the fields its add target form submits, in
|
|
// page order. Only http and slack have a url field.
|
|
targetTypes := []struct {
|
|
name string
|
|
fields string
|
|
values map[string]string
|
|
}{
|
|
{
|
|
"http", "csrf_token name type url headers timeout max_retries",
|
|
map[string]string{"url": publicTargetURL},
|
|
},
|
|
{
|
|
"slack", "csrf_token name type url max_retries",
|
|
map[string]string{"url": publicTargetURL},
|
|
},
|
|
{
|
|
"database", "csrf_token name type expiry",
|
|
map[string]string{"expiry": "720h"},
|
|
},
|
|
{"log", "csrf_token name type", nil},
|
|
}
|
|
|
|
for _, tt := range targetTypes {
|
|
checkAddTarget(
|
|
ctx, t, page, tt.name, strings.Fields(tt.fields), tt.values,
|
|
)
|
|
}
|
|
|
|
checkRefusedTarget(ctx, t, page)
|
|
checkCopy(ctx, t, page)
|
|
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),
|
|
))
|
|
}
|
|
|
|
// checkAddEntrypoint loads a webhook page and checks that the add
|
|
// entrypoint 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 checkAddEntrypoint(ctx context.Context, t *testing.T, url string) {
|
|
t.Helper()
|
|
|
|
form := `form[action$="/entrypoints"]`
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
assert.True(t, hidden(ctx, form),
|
|
"the add entrypoint form shows before Add is clicked")
|
|
|
|
click(ctx, t, `//h2[text()="Entrypoints"]/following-sibling::button`)
|
|
|
|
assert.True(t, shown(ctx, form),
|
|
"the add entrypoint form stays hidden when Add is clicked")
|
|
}
|
|
|
|
// publicTargetURL is a destination the server accepts for an http or
|
|
// slack target. It is a literal public address, so accepting it needs
|
|
// no DNS.
|
|
const publicTargetURL = "https://93.184.216.34/hook"
|
|
|
|
// The parts of the targets section's add target form the checks below
|
|
// find and click. Add is the button beside the Targets heading; each
|
|
// Cancel is found from the button beside it, since both are on the
|
|
// page at once.
|
|
const (
|
|
addTarget = `//h2[text()="Targets"]/following-sibling::button`
|
|
typeSelect = `//select[@aria-label="Target type"]`
|
|
nextButton = `//button[text()="Next"]`
|
|
cancelChoice = nextButton + `/following-sibling::button[text()="Cancel"]`
|
|
saveButton = `//form[contains(@action, "/targets")]//button[text()="Save"]`
|
|
cancelFields = saveButton + `/following-sibling::button[text()="Cancel"]`
|
|
targetName = `form[action$="/targets"] input[name="name"]`
|
|
submittedKeys = `[...new FormData(
|
|
document.querySelector('form[action$="/targets"]')).keys()]`
|
|
)
|
|
|
|
// checkAddTarget loads a webhook page and walks the add target form for
|
|
// one target type. The form shows nothing until Add is clicked; Add
|
|
// shows only the type choice; Cancel there closes it; Next shows the
|
|
// type's own fields in place of the choice, and the form then submits
|
|
// exactly fields, so a field another type uses, such as url, is absent;
|
|
// Cancel closes it again. It then adds a target of the type, filling in
|
|
// values, and checks that the section lists it with that type.
|
|
func checkAddTarget(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
url, targetType string,
|
|
fields []string,
|
|
values map[string]string,
|
|
) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
assert.Truef(t, hidden(ctx, typeSelect),
|
|
"%s: the type choice shows before Add is clicked", targetType)
|
|
assert.Truef(t, hidden(ctx, targetName),
|
|
"%s: the fields show before Add is clicked", targetType)
|
|
|
|
click(ctx, t, addTarget)
|
|
assert.Truef(t, shown(ctx, typeSelect),
|
|
"%s: Add does not show the type choice", targetType)
|
|
assert.Truef(t, hidden(ctx, targetName),
|
|
"%s: Add shows the fields before Next", targetType)
|
|
|
|
click(ctx, t, cancelChoice)
|
|
assert.Truef(t, hidden(ctx, typeSelect),
|
|
"%s: Cancel does not close the type choice", targetType)
|
|
|
|
chooseTargetType(ctx, t, targetType)
|
|
|
|
var submitted []string
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.Evaluate(submittedKeys, &submitted),
|
|
))
|
|
assert.Equalf(t, fields, submitted,
|
|
"%s: the form does not submit exactly the type's fields", targetType)
|
|
|
|
click(ctx, t, cancelFields)
|
|
assert.Truef(t, hidden(ctx, targetName),
|
|
"%s: Cancel does not close the fields", targetType)
|
|
assert.Truef(t, shown(ctx, addTarget),
|
|
"%s: Add does not come back after Cancel", targetType)
|
|
|
|
name := "added-" + targetType
|
|
|
|
chooseTargetType(ctx, t, targetType)
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.SetValue(targetName, name, chromedp.ByQuery),
|
|
))
|
|
|
|
for field, value := range values {
|
|
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
|
`form[action$="/targets"] [name="`+field+`"]`, value,
|
|
chromedp.ByQuery,
|
|
)))
|
|
}
|
|
|
|
click(ctx, t, saveButton)
|
|
assert.Truef(t, shown(ctx, `//span[text()="`+name+
|
|
`"]/following-sibling::div/span[text()="`+targetType+`"]`),
|
|
"%s: the added target is not listed with its type", targetType)
|
|
}
|
|
|
|
// chooseTargetType clicks Add, picks targetType and clicks Next, and
|
|
// checks that the type's fields then show in place of the type choice.
|
|
func chooseTargetType(ctx context.Context, t *testing.T, targetType string) {
|
|
t.Helper()
|
|
|
|
click(ctx, t, addTarget)
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.SetValue(typeSelect, targetType, chromedp.BySearch),
|
|
))
|
|
click(ctx, t, nextButton)
|
|
|
|
assert.Truef(t, shown(ctx, targetName),
|
|
"%s: Next does not show the fields", targetType)
|
|
assert.Truef(t, hidden(ctx, typeSelect),
|
|
"%s: Next leaves the type choice showing", targetType)
|
|
assert.Truef(t, hidden(ctx, addTarget),
|
|
"%s: Add still shows while the form is open", targetType)
|
|
}
|
|
|
|
// checkRefusedTarget submits an http target the server refuses, a
|
|
// loopback destination, and checks that the page comes back with the
|
|
// form open on the http fields, the values entered and the reason.
|
|
func checkRefusedTarget(ctx context.Context, t *testing.T, url string) {
|
|
t.Helper()
|
|
|
|
const (
|
|
refusedURL = "http://127.0.0.1/hook"
|
|
urlField = `form[action$="/targets"] input[name="url"]`
|
|
)
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
chooseTargetType(ctx, t, "http")
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
chromedp.SetValue(targetName, "refused", chromedp.ByQuery),
|
|
chromedp.SetValue(urlField, refusedURL, chromedp.ByQuery),
|
|
))
|
|
|
|
click(ctx, t, saveButton)
|
|
|
|
assert.True(t, shown(ctx, `//div[@class="alert-error"]`),
|
|
"a refused target does not show the reason")
|
|
|
|
var name, typed string
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
chromedp.Value(targetName, &name, chromedp.ByQuery),
|
|
chromedp.Value(urlField, &typed, chromedp.ByQuery),
|
|
))
|
|
|
|
assert.Equal(t, "refused", name,
|
|
"a refused target does not keep the name entered")
|
|
assert.Equal(t, refusedURL, typed,
|
|
"a refused target does not keep the url entered")
|
|
assert.True(t, shown(ctx, targetName),
|
|
"a refused target does not come back with the form open")
|
|
assert.True(t, hidden(ctx, typeSelect),
|
|
"a refused target comes back on the type choice")
|
|
}
|
|
|
|
// 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"`)
|
|
}
|
|
|
|
// 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")
|
|
}
|