//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/dom" "github.com/chromedp/cdproto/input" "github.com/chromedp/cdproto/log" "github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" "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 // olderBody is the body of the event received before the newest. olderBody = "the older event" ) // 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) env.seedEvent(t, webhook.ID, olderBody) 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") checkRecentEvents(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), )) } // 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") } // checkRecentEvents loads a webhook page and checks that of its recent // events only the newest starts expanded, showing its body, that // clicking the older one's row expands it and clicking again collapses // it, and that clicking the newest one's row collapses it. It then // follows the newest one's Open link to the event's own page, which // shows the body. func checkRecentEvents(ctx context.Context, t *testing.T, url string) { t.Helper() // The newest event's body is pretty-printed JSON. Each row's // toggle is the button in the element that holds its state. newest := `//pre[contains(., '"hello": "browser"')]` older := `//pre[text()="` + olderBody + `"]` toggle := `/ancestor::div[@x-data][1]//button` require.NoError(t, chromedp.Run(ctx, loadPage(url))) assert.True(t, shown(ctx, newest), "the newest event starts collapsed") assert.True(t, hidden(ctx, older), "an older event starts expanded") click(ctx, t, older+toggle) assert.True(t, shown(ctx, older), "clicking an event does not expand it") click(ctx, t, older+toggle) assert.True(t, hidden(ctx, older), "clicking an event again does not collapse it") click(ctx, t, newest+toggle) assert.True(t, hidden(ctx, newest), "clicking the newest event does not collapse it") require.NoError(t, chromedp.Run(ctx, loadPage(url))) click(ctx, t, newest+`/ancestor::div[@x-data][1]//a[text()="Open"]`) assert.True(t, shown(ctx, `//h2[text()="Body"]`), "Open does not lead to the event's own page") assert.True(t, shown(ctx, newest), "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. Selecting the ID with the mouse leaves the // event collapsed, and the caret's next click expands it. Clicking the // ID again collapses it. From the keyboard, Enter on the row expands // the event and Space collapses it. func checkEventLog( ctx context.Context, t *testing.T, url, eventID, targetName string, ) { t.Helper() // The event's row shows its ID and ends with its caret, and its // Resubmit form is in the part that expands. The delivery's row // there shows the target's name. id := `//span[text()="` + eventID + `"]` row := id + `/ancestor::div[@role="button"]` caret := row + `//*[local-name()="svg"]` expanded := `form[action$="/` + eventID + `/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, 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, caret) assert.True(t, hidden(ctx, expanded), "clicking the caret does not collapse the event") var ( selected, state string hasState bool ) selectText(ctx, t, id) require.NoError(t, chromedp.Run( ctx, chromedp.Evaluate(`window.getSelection().toString()`, &selected), chromedp.AttributeValue( row, "aria-expanded", &state, &hasState, chromedp.BySearch, ), )) assert.Equal(t, eventID, selected, "the event's ID cannot be selected") require.True(t, hasState, "the event's row does not say if it is expanded") assert.Equal(t, "false", state, "selecting the event's ID expands it") click(ctx, t, caret) assert.True(t, shown(ctx, expanded), "clicking the caret after selecting the ID does not expand the event") click(ctx, t, id) assert.True(t, hidden(ctx, expanded), "clicking the event's ID again does not collapse it") require.NoError(t, chromedp.Run( ctx, chromedp.Focus(row, chromedp.BySearch), chromedp.KeyEvent(kb.Enter), )) assert.True(t, shown(ctx, expanded), "Enter does not expand the event") require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" "))) assert.True(t, hidden(ctx, expanded), "Space does not collapse the event") } // selectText selects the text of the element matching an XPath // expression as a person does with the mouse: pressing the button at the // text's start, moving to its end and releasing it there. func selectText(ctx context.Context, t *testing.T, xpath string) { t.Helper() var box *dom.BoxModel require.NoError(t, chromedp.Run( ctx, chromedp.Dimensions(xpath, &box, chromedp.BySearch), )) // The content box's corners, clockwise from its top left. left, right := box.Content[0]+1, box.Content[2]-1 middle := (box.Content[1] + box.Content[5]) / 2 require.NoError(t, chromedp.Run( ctx, input.DispatchMouseEvent(input.MousePressed, left, middle). WithButton(input.Left).WithButtons(1).WithClickCount(1), input.DispatchMouseEvent(input.MouseMoved, right, middle). WithButton(input.Left).WithButtons(1), input.DispatchMouseEvent(input.MouseReleased, right, middle). WithButton(input.Left).WithClickCount(1), )) } // 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") }