check / check (push) Successful in 3m23s
The new-webhook page keeps name, description and retention and adds an HTTP target URL field and an archive checkbox. A filled-in URL creates an http target named HTTP; a checked archive shows a pruning choice (never, 1h, 12h, 24h, 30d, 90d, 365d) and creates a database target named Archive with that expiry. Each target goes through the add target form's validation, and the webhook, its entrypoint and its targets are created in one transaction. A refused form comes back with every value entered, retention included. Model: opus-5-5
865 lines
27 KiB
Go
865 lines
27 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/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
|
|
|
|
// 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
|
|
|
|
checkAddEntrypoint(ctx, t, page)
|
|
|
|
// 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, page, tt.name, strings.Fields(tt.fields), tt.values,
|
|
)
|
|
}
|
|
|
|
checkRefusedTarget(ctx, t, page)
|
|
checkCopy(ctx, t, page)
|
|
checkEntrypointEdit(ctx, t, page, page+"/events")
|
|
checkRecentEvents(ctx, t, page)
|
|
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
|
|
checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page)
|
|
checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new")
|
|
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
|
|
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),
|
|
))
|
|
}
|
|
|
|
// 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()]`
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// 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 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$="/` + 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, 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")
|
|
}
|
|
|
|
// 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")
|
|
}
|