package server_test import ( "context" "fmt" "net/http" "net/http/httptest" "os/exec" "slices" "strings" "sync" "testing" "time" "github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) 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 ) // TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the // event log in headless Chromium, 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, consoleProblems := 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"}`) require.NoError(t, chromedp.Run( ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")), )) checkAddForms(ctx, t, srv.URL+"/source/"+webhook.ID) checkTargetType(ctx, t) checkEventToggle( ctx, t, srv.URL+"/source/"+webhook.ID+"/logs", event.ID, ) assert.Empty( t, consoleProblems(), "the pages printed console warnings, errors or exceptions", ) } // startBrowser starts headless Chromium for one test. It returns the // context that drives it, and a function listing every console warning // or error and every uncaught exception its pages raised: that is how // Alpine.js reports an expression it cannot run. // // The test is skipped when chromium is not on PATH. The Dockerfile's // test stage installs it, so the image build always runs this check. func startBrowser(t *testing.T) (context.Context, func() []string) { t.Helper() path, err := exec.LookPath("chromium") if err != nil { t.Skipf("chromium is not installed: %v", err) } allocCtx, cancelAlloc := chromedp.NewExecAllocator( t.Context(), append( chromedp.DefaultExecAllocatorOptions[:], chromedp.ExecPath(path), // The image build runs tests as root, where Chromium'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() 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 first element matching a CSS // selector to be rendered, and reports whether it was. 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.ByQuery), ) == nil } // hidden is shown's opposite: it waits for the element 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.ByQuery), ) == nil } // click clicks the first 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", ) } // checkEventToggle loads the event log and checks that clicking an // event's row expands it and clicking again collapses it. func checkEventToggle( ctx context.Context, t *testing.T, url, eventID string, ) { t.Helper() // The row shows the event's ID; its Resubmit form is in the part // that expands. row := `//span[text()="` + eventID + `"]` expanded := `form[action$="/resubmit"]` require.NoError(t, chromedp.Run(ctx, loadPage(url))) assert.True(t, hidden(ctx, expanded), "the event starts expanded") click(ctx, t, row) assert.True(t, shown(ctx, expanded), "clicking the event does not expand it") click(ctx, t, row) assert.True(t, hidden(ctx, expanded), "clicking it again does not collapse it") }