check / check (push) Waiting to run
The pages' Content-Security-Policy forbids eval, which the standard Alpine.js build needs, so no directive ran: add forms showed open and events never collapsed. 3p/ now holds the @alpinejs/csp 3.14.9 tarball instead, and every directive in templates/ names a property or method of a component registered in static/js/app.js, as that build requires. The policy is unchanged. A headless Chromium test in internal/server loads the webhook page and the event log under the real headers. The Dockerfile's test stage installs chromium; where it is missing the test skips. Model: opus-5-5
277 lines
7.2 KiB
Go
277 lines
7.2 KiB
Go
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")
|
|
}
|