check / check (push) Successful in 3m35s
The event log now loads the 50 newest events in its query and opens only the newest on load, as the recent events on a webhook's page already do. With 50 at most there is never a second page, so paging is gone: the page links, the `page` query parameter, the page number that Replay and Resubmit carried back to the log, and `pageOrFirst` with its test. The count beside the heading reads "50 most recent of N events" when there are more. The comments and README lines that named `page` as the query parameter the service reads now name `next` and `notice`. Model: opus-5-5
1271 lines
42 KiB
Go
1271 lines
42 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/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
|
|
|
|
// A window short enough that the event log scrolls with its last
|
|
// event expanded, and tall enough to show all of that event.
|
|
shortWidth = 1024
|
|
shortHeight = 450
|
|
|
|
// A person's double- or triple-click: each press is held a tenth of a
|
|
// second, and the next press comes a quarter second after the release.
|
|
pressHeld = 100 * time.Millisecond
|
|
betweenClicks = 250 * time.Millisecond
|
|
|
|
// 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, and that a target's
|
|
// row shows its delivery counts.
|
|
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, older, event, target := seedBrowserWebhook(t, env, userID)
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, setCookies(srv.URL, env.authCookies(t, userID, "browser")),
|
|
))
|
|
|
|
page := srv.URL + "/hook/" + webhook.ID
|
|
|
|
// The checks share one browser tab, so they run one at a time, in
|
|
// this order. A new check is one more line here.
|
|
checkAddEntrypoint(ctx, t, page)
|
|
checkAddEachTargetType(ctx, t, page)
|
|
checkArchiveExpiry(ctx, t, page)
|
|
checkRefusedTarget(ctx, t, page)
|
|
checkTargetDeliveries(ctx, t, page, target.Name,
|
|
"0 in total, 0 in the last 24 hours",
|
|
"1 in total, 1 in the last 24 hours")
|
|
checkRefusedEdits(ctx, t, page, target.ID)
|
|
checkCopy(ctx, t, page)
|
|
checkEntrypointEdit(ctx, t, page, page+"/events")
|
|
checkRecentEvents(ctx, t, page)
|
|
checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page)
|
|
checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new")
|
|
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
|
|
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
|
|
checkMobileMenu(ctx, t, page)
|
|
|
|
assert.Empty(t, problems(), "the browser reported problems")
|
|
}
|
|
|
|
// seedBrowserWebhook seeds the webhook the browser test loads, owned by
|
|
// userID: an entrypoint, two events, and a target whose delivery of the
|
|
// newer event failed once with a 502. It returns the webhook, the older
|
|
// and the newer event, and the target.
|
|
func seedBrowserWebhook(
|
|
t *testing.T, env *testEnv, userID string,
|
|
) (*database.Webhook, *database.Event, *database.Event, *database.Target) {
|
|
t.Helper()
|
|
|
|
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)
|
|
older := 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)
|
|
|
|
return webhook, older, event, target
|
|
}
|
|
|
|
// 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()]`
|
|
)
|
|
|
|
// checkAddEachTargetType runs checkAddTarget on a webhook page for each
|
|
// target type, in page order.
|
|
func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
|
|
t.Helper()
|
|
|
|
// 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, url, tt.name, strings.Fields(tt.fields), tt.values,
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// checkArchiveExpiry loads a webhook page and checks that the add
|
|
// target form's archive expiry starts on never, that the database
|
|
// target checkAddTarget added with 720h is listed as 30 days, and that
|
|
// its edit form starts on 720h.
|
|
func checkArchiveExpiry(ctx context.Context, t *testing.T, url string) {
|
|
t.Helper()
|
|
|
|
const expiry = `form[action$="/targets"] select[name="expiry"]`
|
|
|
|
row := `//span[text()="added-database"]/ancestor::div[@class="p-4"][1]`
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
chooseTargetType(ctx, t, "database")
|
|
|
|
var start, edited string
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.Value(expiry, &start, chromedp.ByQuery),
|
|
))
|
|
assert.Equal(t, "never", start,
|
|
"the add target form's archive expiry does not start on never")
|
|
|
|
assert.True(t, shown(ctx, row+`//span[text()="Archive Expiry:"]`+
|
|
`/following-sibling::span[text()="30 days"]`),
|
|
"a database target added with 720h is not listed as 30 days")
|
|
|
|
click(ctx, t, row+`//a[text()="Edit"]`)
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
chromedp.WaitReady("#expiry", chromedp.ByQuery),
|
|
chromedp.Value("#expiry", &edited, chromedp.ByQuery),
|
|
))
|
|
assert.Equal(t, "720h", edited,
|
|
"the edit form does not start on the stored archive expiry")
|
|
}
|
|
|
|
// 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, and
|
|
// that after Cancel the next Add starts with an empty form and no
|
|
// 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"]`
|
|
reason = `//div[@class="alert-error"]`
|
|
)
|
|
|
|
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, reason),
|
|
"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")
|
|
|
|
click(ctx, t, cancelFields)
|
|
chooseTargetType(ctx, t, "http")
|
|
|
|
assert.True(t, hidden(ctx, reason),
|
|
"after Cancel, the next Add still shows the reason")
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
chromedp.Value(targetName, &name, chromedp.ByQuery),
|
|
chromedp.Value(urlField, &typed, chromedp.ByQuery),
|
|
))
|
|
|
|
assert.Empty(t, name, "after Cancel, the next Add keeps the name entered")
|
|
assert.Empty(t, typed, "after Cancel, the next Add keeps the url entered")
|
|
}
|
|
|
|
// checkTargetDeliveries loads a webhook page and checks that the row of
|
|
// the target named name shows delivered and failed beside its
|
|
// "Delivered:" and "Failed:" labels.
|
|
func checkTargetDeliveries(
|
|
ctx context.Context, t *testing.T, url, name, delivered, failed string,
|
|
) {
|
|
t.Helper()
|
|
|
|
row := `//span[text()="` + name + `"]/ancestor::div[@class="p-4"][1]`
|
|
figure := func(label, value string) string {
|
|
return row + `//span[text()="` + label +
|
|
`"]/following-sibling::span[text()="` + value + `"]`
|
|
}
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
assert.Truef(t, shown(ctx, figure("Delivered:", delivered)),
|
|
"the row of %s does not show %q delivered", name, delivered)
|
|
assert.Truef(t, shown(ctx, figure("Failed:", failed)),
|
|
"the row of %s does not show %q failed", name, failed)
|
|
}
|
|
|
|
// checkRefusedEdits fills in the target edit page and the webhook edit
|
|
// page of a webhook page with values the server refuses, a loopback
|
|
// destination and a retention above the longest finite one, which the
|
|
// browser lets through. It saves each and checks that the page comes
|
|
// back with the reason and every value still in its field. The values
|
|
// are keyed by the id of their field.
|
|
func checkRefusedEdits(
|
|
ctx context.Context, t *testing.T, page, targetID string,
|
|
) {
|
|
t.Helper()
|
|
|
|
const reason = `//div[@class="alert-error"]`
|
|
|
|
edits := []struct {
|
|
url string
|
|
values map[string]string
|
|
}{
|
|
{page + "/targets/" + targetID + "/edit", map[string]string{
|
|
"#name": "edited-target",
|
|
"#url": "http://127.0.0.1/hook",
|
|
"#headers": "X-Edited: kept",
|
|
"#timeout": "12",
|
|
"#max_retries": "3",
|
|
}},
|
|
{page + "/edit", map[string]string{
|
|
"#name": "edited-webhook",
|
|
"#description": "kept description",
|
|
"#retention_days": "200000",
|
|
}},
|
|
}
|
|
|
|
for _, edit := range edits {
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(edit.url)))
|
|
|
|
for field, value := range edit.values {
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.SetValue(field, value, chromedp.ByQuery),
|
|
))
|
|
}
|
|
|
|
click(ctx, t, `//button[text()="Save Changes"]`)
|
|
|
|
assert.Truef(t, shown(ctx, reason),
|
|
"%s: a refused save does not show the reason", edit.url)
|
|
|
|
for field, value := range edit.values {
|
|
var kept string
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.Value(field, &kept, chromedp.ByQuery),
|
|
))
|
|
assert.Equalf(t, value, kept,
|
|
"%s: a refused save does not keep the %s entered",
|
|
edit.url, field)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
|
|
// Cancel and Save are found inside the edit form, since the add
|
|
// target form has buttons of the same names.
|
|
const (
|
|
editForm = `form[action$="/edit"]`
|
|
input = editForm + ` input[name="description"]`
|
|
cancelEdit = `//form[contains(@action, "/edit")]/button[text()="Cancel"]`
|
|
saveEdit = `//form[contains(@action, "/edit")]/button[text()="Save"]`
|
|
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, cancelEdit)
|
|
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, saveEdit)
|
|
|
|
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 that of its events only
|
|
// the newest, eventID, starts expanded: its row says so and its caret is
|
|
// turned up, and the log's last event, lastEventID, starts collapsed. In
|
|
// the newest event, clicking its delivery shows the delivery's attempts
|
|
// and clicking again hides them. Clicking the row's caret collapses the
|
|
// event, clicking it again expands it, clicking its ID collapses it and
|
|
// clicking the ID again expands it. While the event is collapsed the row
|
|
// says so and its caret is turned down. It then runs checkEventSelection
|
|
// on lastEventID and checkEventKeyboard on eventID.
|
|
func checkEventLog(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
url, eventID, lastEventID, targetName string,
|
|
) {
|
|
t.Helper()
|
|
|
|
// The event's row shows its ID and ends with its caret, which turns
|
|
// up with Tailwind's rotate-180 class, 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"]`
|
|
caretUp := caret + `[contains(@class, "rotate-180")]`
|
|
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
|
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
|
lastExpanded := `form[action$="/` + lastEventID + `/resubmit"]`
|
|
deliveryRow := `//span[text()="` + targetName + `"]`
|
|
attempt := `//span[text()="Attempt 1"]`
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
assert.True(t, shown(ctx, expanded), "the newest event starts collapsed")
|
|
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
|
|
"the expanded event's row does not say it is expanded")
|
|
assert.True(t, shown(ctx, caretUp),
|
|
"the expanded event's caret does not turn up")
|
|
assert.True(t, hidden(ctx, lastExpanded),
|
|
"an older event starts expanded")
|
|
|
|
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")
|
|
assert.True(t, shown(ctx, row+`[@aria-expanded="false"]`),
|
|
"the collapsed event's row does not say it is collapsed")
|
|
assert.True(t, shown(ctx, caretDown),
|
|
"the collapsed event's caret stays turned up")
|
|
|
|
click(ctx, t, caret)
|
|
assert.True(t, shown(ctx, expanded),
|
|
"clicking the caret again does not expand the event")
|
|
|
|
click(ctx, t, id)
|
|
assert.True(t, hidden(ctx, expanded),
|
|
"clicking the event's ID does not collapse it")
|
|
|
|
click(ctx, t, id)
|
|
assert.True(t, shown(ctx, expanded),
|
|
"clicking the event's ID again does not expand it")
|
|
|
|
checkEventSelection(ctx, t, url, lastEventID)
|
|
checkEventKeyboard(ctx, t, url, eventID)
|
|
}
|
|
|
|
// checkEventSelection loads the event log in a short window and checks
|
|
// that selecting the ID of its last event, eventID, with the mouse leaves
|
|
// the event as it was, and that its caret toggles it at once. Dragging
|
|
// over the ID leaves the event collapsed, and the caret's click expands
|
|
// it at once. With the page then scrolled to its end, a double-click on
|
|
// the ID that goes on to drag along it, and a triple-click on it, each
|
|
// leave the event expanded and select that ID. Had a click there
|
|
// collapsed the event, the page would have got shorter and moved under
|
|
// the pointer before the next click.
|
|
func checkEventSelection(
|
|
ctx context.Context, t *testing.T, url, eventID string,
|
|
) {
|
|
t.Helper()
|
|
|
|
id := `//span[text()="` + eventID + `"]`
|
|
row := id + `/ancestor::div[@role="button"]`
|
|
caret := row + `//*[local-name()="svg"]`
|
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
|
|
|
var (
|
|
selected, state string
|
|
hasState bool
|
|
scrolled float64
|
|
)
|
|
|
|
// What is selected, and whether the event's row says it is expanded.
|
|
read := chromedp.Tasks{
|
|
chromedp.Evaluate(`window.getSelection().toString()`, &selected),
|
|
chromedp.AttributeValue(
|
|
row, "aria-expanded", &state, &hasState, chromedp.BySearch,
|
|
),
|
|
}
|
|
|
|
// A click on the ID toggles the event half a second after it, so a
|
|
// check that selecting the ID did not toggle it waits a second first.
|
|
settle := chromedp.Sleep(time.Second)
|
|
|
|
// The double-click and the triple-click each start with nothing
|
|
// selected, so that their first click waits to toggle the event.
|
|
clearSelection := chromedp.Evaluate(
|
|
`window.getSelection().removeAllRanges()`, nil,
|
|
)
|
|
|
|
// The newest event starts expanded, which can push this one below
|
|
// the short window, where the mouse cannot reach it.
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url),
|
|
chromedp.ScrollIntoView(id, chromedp.BySearch),
|
|
))
|
|
|
|
selectText(ctx, t, id)
|
|
require.NoError(t, chromedp.Run(ctx, settle, read))
|
|
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)
|
|
require.NoError(t, chromedp.Run(ctx, read))
|
|
assert.Equal(t, "true", state,
|
|
"clicking the caret does not expand the event at once")
|
|
|
|
// The row says it is expanded before its expanded part is shown, so
|
|
// the scroll waits for that part, to end at the expanded page's end.
|
|
require.True(t, shown(ctx, expanded),
|
|
"clicking the caret does not show the event's expanded part")
|
|
|
|
require.NoError(t, chromedp.Run(ctx, chromedp.Evaluate(
|
|
`window.scrollTo(0, document.body.scrollHeight); window.scrollY`,
|
|
&scrolled,
|
|
)))
|
|
require.Positive(t, scrolled, "the event log does not scroll")
|
|
|
|
require.NoError(t, chromedp.Run(ctx, clearSelection))
|
|
doubleClickAndDrag(ctx, t, id)
|
|
require.NoError(t, chromedp.Run(ctx, settle, read))
|
|
assert.Contains(t, selected, eventID,
|
|
"a double-click and drag does not select the event's ID")
|
|
assert.Equal(t, "true", state,
|
|
"a double-click and drag over the event's ID collapses it")
|
|
|
|
require.NoError(t, chromedp.Run(ctx, clearSelection))
|
|
tripleClick(ctx, t, id)
|
|
require.NoError(t, chromedp.Run(ctx, settle, read))
|
|
assert.Contains(t, selected, eventID,
|
|
"a triple-click does not select the event's ID")
|
|
assert.Equal(t, "true", state,
|
|
"a triple-click selecting the event's ID collapses it")
|
|
}
|
|
|
|
// checkEventKeyboard loads the event log and checks that Tab from the
|
|
// page's Back link reaches the row of the newest event, the first after
|
|
// it, and that Enter then collapses that event, which starts expanded,
|
|
// and Space expands it again.
|
|
func checkEventKeyboard(
|
|
ctx context.Context, t *testing.T, url, eventID string,
|
|
) {
|
|
t.Helper()
|
|
|
|
back := `//a[contains(text(), "Back to")]`
|
|
expanded := `form[action$="/` + eventID + `/resubmit"]`
|
|
|
|
var focused string
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
loadPage(url),
|
|
chromedp.Focus(back, chromedp.BySearch),
|
|
chromedp.KeyEvent(kb.Tab),
|
|
chromedp.Evaluate(`document.activeElement.textContent`, &focused),
|
|
))
|
|
require.Contains(t, focused, eventID,
|
|
"Tab from the Back link does not reach the event's row")
|
|
|
|
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
|
|
assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event")
|
|
|
|
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
|
|
assert.True(t, shown(ctx, expanded), "Space does not expand 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()
|
|
|
|
left, right, y := textEnds(ctx, t, xpath)
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx, press(left, y, 1), drag(right, y), release(right, y, 1),
|
|
))
|
|
}
|
|
|
|
// doubleClickAndDrag double-clicks the start of the text of the element
|
|
// matching an XPath expression, which selects its first word, and keeps
|
|
// the button down to drag to the text's end, which selects it word by
|
|
// word. It holds the button for a second, longer than a single click on
|
|
// an event's row waits before it toggles the event.
|
|
func doubleClickAndDrag(ctx context.Context, t *testing.T, xpath string) {
|
|
t.Helper()
|
|
|
|
left, right, y := textEnds(ctx, t, xpath)
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
press(left, y, 1), chromedp.Sleep(pressHeld), release(left, y, 1),
|
|
chromedp.Sleep(betweenClicks),
|
|
press(left, y, 2), drag(right, y), chromedp.Sleep(time.Second),
|
|
release(right, y, 2),
|
|
))
|
|
}
|
|
|
|
// tripleClick clicks three times in the middle of the text of the
|
|
// element matching an XPath expression, as a person does to select a
|
|
// whole line of text. The browser selects a word on the second click and
|
|
// the whole paragraph on the third.
|
|
func tripleClick(ctx context.Context, t *testing.T, xpath string) {
|
|
t.Helper()
|
|
|
|
left, right, y := textEnds(ctx, t, xpath)
|
|
x := (left + right) / 2
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
press(x, y, 1), chromedp.Sleep(pressHeld), release(x, y, 1),
|
|
chromedp.Sleep(betweenClicks),
|
|
press(x, y, 2), chromedp.Sleep(pressHeld), release(x, y, 2),
|
|
chromedp.Sleep(betweenClicks),
|
|
press(x, y, 3), chromedp.Sleep(pressHeld), release(x, y, 3),
|
|
))
|
|
}
|
|
|
|
// textEnds returns where on screen the text of the element matching an
|
|
// XPath expression starts and ends, just inside its left and right
|
|
// edges, and the height of its middle: in that order, the x of its
|
|
// start, the x of its end, and the y of both.
|
|
func textEnds(
|
|
ctx context.Context, t *testing.T, xpath string,
|
|
) (float64, float64, float64) {
|
|
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.
|
|
return box.Content[0] + 1, box.Content[2] - 1,
|
|
(box.Content[1] + box.Content[5]) / 2
|
|
}
|
|
|
|
// press presses the left mouse button at x, y, as the nth click of a
|
|
// double- or triple-click.
|
|
func press(x, y float64, nth int64) *input.DispatchMouseEventParams {
|
|
return input.DispatchMouseEvent(input.MousePressed, x, y).
|
|
WithButton(input.Left).WithButtons(1).WithClickCount(nth)
|
|
}
|
|
|
|
// drag moves the pointer to x, y with the left mouse button down.
|
|
func drag(x, y float64) *input.DispatchMouseEventParams {
|
|
return input.DispatchMouseEvent(input.MouseMoved, x, y).
|
|
WithButton(input.Left).WithButtons(1)
|
|
}
|
|
|
|
// release releases the left mouse button at x, y, as the nth click of a
|
|
// double- or triple-click.
|
|
func release(x, y float64, nth int64) *input.DispatchMouseEventParams {
|
|
return input.DispatchMouseEvent(input.MouseReleased, x, y).
|
|
WithButton(input.Left).WithClickCount(nth)
|
|
}
|
|
|
|
// The parts of the new webhook page the checks below find and click.
|
|
const (
|
|
archiveBox = `//input[@name="archive"]`
|
|
archiveIsOn = `document.querySelector('input[name="archive"]').checked`
|
|
pruningChoice = `//select[@name="archive_expiry"]`
|
|
createButton = `//button[text()="Create Webhook"]`
|
|
)
|
|
|
|
// checkArchiveChoice loads the new webhook page and checks that the
|
|
// archive pruning choice stays hidden until the archive box is checked
|
|
// and hides again when it is unchecked; and that after checking it,
|
|
// opening the page at elsewhere and going back, the page again shows
|
|
// the box unchecked and the choice hidden.
|
|
func checkArchiveChoice(
|
|
ctx context.Context, t *testing.T, url, elsewhere string,
|
|
) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
|
|
|
|
assert.True(t, hidden(ctx, pruningChoice),
|
|
"the pruning choice shows before archive is checked")
|
|
|
|
click(ctx, t, archiveBox)
|
|
assert.True(t, shown(ctx, pruningChoice),
|
|
"checking archive does not show the pruning choice")
|
|
|
|
click(ctx, t, archiveBox)
|
|
assert.True(t, hidden(ctx, pruningChoice),
|
|
"unchecking archive does not hide the pruning choice")
|
|
|
|
var (
|
|
loaded string
|
|
checked bool
|
|
)
|
|
|
|
click(ctx, t, archiveBox)
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
loadPage(elsewhere),
|
|
chromedp.NavigateBack(),
|
|
chromedp.WaitNotPresent("[x-cloak]", chromedp.ByQuery),
|
|
chromedp.Evaluate(
|
|
`performance.getEntriesByType("navigation")[0].type`, &loaded,
|
|
),
|
|
chromedp.Evaluate(archiveIsOn, &checked),
|
|
))
|
|
require.Equal(
|
|
t, "back_forward", loaded,
|
|
"going back, the browser did not load the page again",
|
|
)
|
|
|
|
assert.False(t, checked, "going back leaves archive checked")
|
|
assert.True(t, hidden(ctx, pruningChoice),
|
|
"going back shows the pruning choice")
|
|
}
|
|
|
|
// checkNewWebhookTargets submits the new webhook page with the HTTP
|
|
// target URL filled in or empty, and with archive left off or checked
|
|
// with each pruning choice, and checks that each webhook is created
|
|
// with exactly the targets asked for.
|
|
func checkNewWebhookTargets(
|
|
ctx context.Context, t *testing.T, env *testEnv, url string,
|
|
) {
|
|
t.Helper()
|
|
|
|
// Each value the pruning choice submits, after an empty one that
|
|
// stands for archive left off.
|
|
expiries := []string{
|
|
"", "never", "1h", "12h", "24h", "720h", "2160h", "8760h",
|
|
}
|
|
|
|
for _, httpURL := range []string{"", publicTargetURL} {
|
|
for _, expiry := range expiries {
|
|
name := "url=" + httpURL + " archive=" + expiry
|
|
want := map[database.TargetType]string{}
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
loadPage(url),
|
|
chromedp.SetValue("#name", name, chromedp.ByQuery),
|
|
))
|
|
|
|
if httpURL != "" {
|
|
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
|
"#http_url", httpURL, chromedp.ByQuery,
|
|
)))
|
|
|
|
want[database.TargetTypeHTTP] = `{"url":"` + httpURL + `"}`
|
|
}
|
|
|
|
if expiry != "" {
|
|
// The choice showing moves Create down, so it is
|
|
// waited for before Create is clicked.
|
|
click(ctx, t, archiveBox)
|
|
require.Truef(t, shown(ctx, pruningChoice),
|
|
"%s: checking archive does not show the pruning choice",
|
|
name)
|
|
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
|
pruningChoice, expiry, chromedp.BySearch,
|
|
)))
|
|
|
|
want[database.TargetTypeDatabase] = `{"expiry":"` + expiry + `"}`
|
|
}
|
|
|
|
click(ctx, t, createButton)
|
|
require.Truef(t, shown(ctx, `//h1[text()="`+name+`"]`),
|
|
"%s: the new webhook's page does not open", name)
|
|
|
|
assert.Equalf(t, want, targetConfigs(t, env, name),
|
|
"%s: the webhook does not have the targets asked for", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// targetConfigs reads the targets of the webhook named name, and
|
|
// returns each one's stored configuration by its type.
|
|
func targetConfigs(
|
|
t *testing.T, env *testEnv, name string,
|
|
) map[database.TargetType]string {
|
|
t.Helper()
|
|
|
|
var (
|
|
webhook database.Webhook
|
|
targets []database.Target
|
|
)
|
|
|
|
require.NoError(t, env.db.DB().
|
|
Where("name = ?", name).First(&webhook).Error)
|
|
require.NoError(t, env.db.DB().
|
|
Where("webhook_id = ?", webhook.ID).Find(&targets).Error)
|
|
|
|
configs := map[database.TargetType]string{}
|
|
for _, target := range targets {
|
|
configs[target.Type] = target.Config
|
|
}
|
|
|
|
return configs
|
|
}
|
|
|
|
// checkRefusedNewWebhook submits the new webhook page with archive
|
|
// checked and an HTTP target URL the server refuses, a loopback
|
|
// destination, and checks that the page comes back with the reason and
|
|
// every value entered, archive still checked and its pruning choice
|
|
// showing.
|
|
func checkRefusedNewWebhook(ctx context.Context, t *testing.T, url string) {
|
|
t.Helper()
|
|
|
|
const refusedURL = "http://127.0.0.1/hook"
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
loadPage(url),
|
|
chromedp.SetValue("#name", "refused", chromedp.ByQuery),
|
|
chromedp.SetValue("#description", "kept", chromedp.ByQuery),
|
|
chromedp.SetValue("#retention_days", "7", chromedp.ByQuery),
|
|
chromedp.SetValue("#http_url", refusedURL, chromedp.ByQuery),
|
|
))
|
|
click(ctx, t, archiveBox)
|
|
require.True(t, shown(ctx, pruningChoice),
|
|
"checking archive does not show the pruning choice")
|
|
require.NoError(t, chromedp.Run(ctx, chromedp.SetValue(
|
|
pruningChoice, "2160h", chromedp.BySearch,
|
|
)))
|
|
click(ctx, t, createButton)
|
|
|
|
assert.True(t, shown(ctx, `//div[@class="alert-error"]`),
|
|
"a refused webhook does not show the reason")
|
|
|
|
var (
|
|
name, description, retention, typed, expiry string
|
|
checked bool
|
|
)
|
|
|
|
require.NoError(t, chromedp.Run(
|
|
ctx,
|
|
chromedp.Value("#name", &name, chromedp.ByQuery),
|
|
chromedp.Value("#description", &description, chromedp.ByQuery),
|
|
chromedp.Value("#retention_days", &retention, chromedp.ByQuery),
|
|
chromedp.Value("#http_url", &typed, chromedp.ByQuery),
|
|
chromedp.Value("#archive_expiry", &expiry, chromedp.ByQuery),
|
|
chromedp.Evaluate(archiveIsOn, &checked),
|
|
))
|
|
|
|
assert.Equal(t, "refused", name, "the name entered is lost")
|
|
assert.Equal(t, "kept", description, "the description entered is lost")
|
|
assert.Equal(t, "7", retention, "the retention entered is lost")
|
|
assert.Equal(t, refusedURL, typed, "the url entered is lost")
|
|
assert.True(t, checked, "archive is no longer checked")
|
|
assert.True(t, shown(ctx, pruningChoice), "the pruning choice is hidden")
|
|
assert.Equal(t, "2160h", expiry, "the pruning chosen is lost")
|
|
}
|
|
|
|
// 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")
|
|
}
|