//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 + "/source/" + webhook.ID checkAddForms(ctx, t, page) checkTargetType(ctx, t) checkEventLog(ctx, t, page+"/logs", 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. func checkTargetType(ctx context.Context, t *testing.T) { t.Helper() const ( chooseSlack = `(() => { const type = document.querySelector('select[name="type"]'); type.value = "slack"; type.dispatchEvent(new Event("change")); })()` submitted = `[...new FormData( document.querySelector('form[action$="/targets"]')).keys()]` ) var fields []string require.NoError(t, chromedp.Run( ctx, chromedp.Evaluate(chooseSlack, nil), chromedp.Evaluate(submitted, &fields), )) assert.Equal( t, []string{"csrf_token", "name", "type", "max_retries", "url"}, fields, "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") }