mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-10-23 05:32:05 +00:00
Set up e2e test harness
This commit is contained in:
1 parent
477bcf6ab5
commit
0b54870fa1
4 files changed
+382
No files matched your search
@@ -6,6 +6,8 @@ require (
|
||||
github.com/alexedwards/scs/pgxstore v0.0.0-20251002162104-209de6e426de
|
||||
github.com/alexedwards/scs/v2 v2.9.0
|
||||
github.com/brianvoe/gofakeit/v7 v7.15.0
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
|
||||
github.com/chromedp/chromedp v0.15.1
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
@@ -28,6 +30,7 @@ require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
@@ -40,11 +43,15 @@ require (
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
@@ -20,6 +20,12 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
|
||||
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
|
||||
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
@@ -54,6 +60,8 @@ github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -67,6 +75,12 @@ github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHl
|
||||
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
@@ -102,6 +116,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
@@ -142,6 +158,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/peterstace/simplefeatures v0.59.0 h1:pmn+uh75K3CCGsJCLHnpBqgQmDECLYX3u5hfymVbqmQ=
|
||||
github.com/peterstace/simplefeatures v0.59.0/go.mod h1:0QH884YeU4jOeM6Bh7EDdDFyYU1L0I0QONxwwFiknqc=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
@@ -240,6 +258,7 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
//go:build browser
|
||||
|
||||
// Package e2e holds browser (end-to-end) tests: they drive a real headless
|
||||
// Chrome against a live server, validating things Go tests can't — rendered
|
||||
// DOM, client JS, and (critically) that the strict CSP doesn't break a page
|
||||
// (CSP violations only surface in the browser console).
|
||||
//
|
||||
// The suite is deliberately its own black-box package, depending only on the
|
||||
// public surface of the app (core.NewServer, the store/auth/identity
|
||||
// constructors, the /session/callback handoff), so browser tests accumulate
|
||||
// here without cluttering — or coupling to the internals of — the shipping
|
||||
// packages. Add a new feature's test as its own <feature>_test.go and reuse the
|
||||
// e2eServer harness below.
|
||||
//
|
||||
// Everything is gated behind the `browser` build tag so the default
|
||||
// `go test ./...` (and current CI) never needs a browser. Run it with
|
||||
// `mise run e2e`, which starts a chromedp/headless-shell container. If that
|
||||
// container isn't reachable the tests t.Skip rather than fail.
|
||||
//
|
||||
// Networking note: the browser runs inside the container while the test server
|
||||
// runs on the Docker host, so the httptest listener binds 0.0.0.0 and the
|
||||
// browser reaches it via host.docker.internal (both addresses are
|
||||
// env-overridable for CI — see devtoolsBase/browserHost).
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cdplog "github.com/chromedp/cdproto/log"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
meshcore "github.com/meshcore-go/meshcore-go"
|
||||
|
||||
"github.com/jleight/meshtender/internal/auth"
|
||||
"github.com/jleight/meshtender/internal/config"
|
||||
"github.com/jleight/meshtender/internal/core"
|
||||
"github.com/jleight/meshtender/internal/identity"
|
||||
"github.com/jleight/meshtender/internal/store"
|
||||
"github.com/jleight/meshtender/internal/testdb"
|
||||
)
|
||||
|
||||
// TestMain manages the shared testdb container/template for the whole package.
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(testdb.RunMain(m))
|
||||
}
|
||||
|
||||
// migrate applies the schema to the template database (cloned per test by
|
||||
// testdb.Fresh), releasing its connection before the clone.
|
||||
func migrate(dsn string) error {
|
||||
ctx := context.Background()
|
||||
st, err := store.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
return st.Migrate(ctx)
|
||||
}
|
||||
|
||||
// The harness talks to a browser it doesn't launch, over addresses that differ
|
||||
// between local Docker and CI, so both are env-overridable:
|
||||
// - devtoolsBase: where headless-shell's DevTools endpoint lives. Locally the
|
||||
// published port on the Docker host; in CI the service hostname.
|
||||
// - browserHost: how the browser reaches back to this process's test server.
|
||||
// Locally host.docker.internal; in CI this step's service hostname.
|
||||
func devtoolsBase() string { return envOr("E2E_DEVTOOLS_URL", "http://127.0.0.1:9222") }
|
||||
func browserHost() string { return envOr("E2E_BROWSER_HOST", "host.docker.internal") }
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// e2eServer is a running app server wired to a fresh test DB, addressable both
|
||||
// from the host (hostURL, for the seed-session Go client) and from the browser
|
||||
// container (browserURL).
|
||||
type e2eServer struct {
|
||||
store *store.Store
|
||||
ctx context.Context
|
||||
ts *httptest.Server
|
||||
hostURL string // http://127.0.0.1:PORT — reachable from this process
|
||||
browserURL string // http://host.docker.internal:PORT — reachable from the container
|
||||
}
|
||||
|
||||
// newE2EServer stands up the app on a 0.0.0.0 listener so the browser container
|
||||
// can connect back to it, and returns both address forms.
|
||||
func newE2EServer(t *testing.T) *e2eServer {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.New(ctx, testdb.Fresh(t, migrate))
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
|
||||
var masterKey [32]byte
|
||||
_, _ = rand.Read(masterKey[:])
|
||||
idSvc, _ := identity.LoadOrCreate(ctx, st, masterKey)
|
||||
authSvc, err := auth.New(st, st.Pool(), auth.Config{
|
||||
RPID: "localhost", RPDisplayName: "test", RPOrigins: []string{"http://localhost"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("auth: %v", err)
|
||||
}
|
||||
// Empty config → the Dispatcher serves every host from the app surface, so
|
||||
// no Host-header juggling is needed (the browser hits host.docker.internal).
|
||||
srv, err := core.NewServer(st, authSvc, idSvc, &config.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("server: %v", err)
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", "0.0.0.0:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
ts := httptest.NewUnstartedServer(srv.Handler())
|
||||
ts.Listener.Close() // drop the default 127.0.0.1 listener
|
||||
ts.Listener = ln
|
||||
ts.Start()
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
// ts.URL reflects the 0.0.0.0 listener; rewrite it to loopback so the
|
||||
// host-side Go client dials a concrete address.
|
||||
ts.URL = fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
return &e2eServer{
|
||||
store: st,
|
||||
ctx: ctx,
|
||||
ts: ts,
|
||||
hostURL: ts.URL,
|
||||
browserURL: fmt.Sprintf("http://%s:%d", browserHost(), port),
|
||||
}
|
||||
}
|
||||
|
||||
// login creates a user, establishes an authenticated session via the
|
||||
// /session/callback handoff (the same seam the HTTP handler tests use, but
|
||||
// reached only through public APIs), and returns the user plus the session
|
||||
// cookie to inject into the browser.
|
||||
func (e *e2eServer) login(t *testing.T, username string) (*store.User, *http.Cookie) {
|
||||
t.Helper()
|
||||
u, err := e.store.CreateUser(e.ctx, username, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
loginID, err := e.store.CreateLogin(e.ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create login: %v", err)
|
||||
}
|
||||
code, err := e.store.CreateAuthCode(e.ctx, u.ID, loginID, "/")
|
||||
if err != nil {
|
||||
t.Fatalf("create auth code: %v", err)
|
||||
}
|
||||
|
||||
jar, _ := cookiejar.New(nil)
|
||||
req, _ := http.NewRequest(http.MethodGet, e.hostURL+"/session/callback?code="+code+"&state=s1", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "mt_state", Value: "s1"})
|
||||
client := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("session callback: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
base, _ := url.Parse(e.hostURL)
|
||||
for _, c := range jar.Cookies(base) {
|
||||
if c.Name == "meshtender_session" {
|
||||
return u, c
|
||||
}
|
||||
}
|
||||
t.Fatal("no session cookie after handoff")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// newRepeater creates a repeater owned by ownerID with a valid MeshCore key.
|
||||
func (e *e2eServer) newRepeater(t *testing.T, ownerID int64, name string) *store.Repeater {
|
||||
t.Helper()
|
||||
id, _ := meshcore.GenerateLocalIdentity(rand.Reader)
|
||||
rep, err := e.store.CreateRepeater(e.ctx, &store.Repeater{
|
||||
OwnerID: ownerID, Name: name, PublicKeyHex: id.String(),
|
||||
RadioFreqHz: 869525000, RadioBwHz: 250000, RadioSF: 11, RadioCR: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create repeater: %v", err)
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// consoleWatch records browser console errors and CSP violations seen during a
|
||||
// test, so a test can assert the page ran cleanly under the strict CSP.
|
||||
type consoleWatch struct {
|
||||
mu sync.Mutex
|
||||
msgs []string
|
||||
}
|
||||
|
||||
func (w *consoleWatch) add(s string) {
|
||||
w.mu.Lock()
|
||||
w.msgs = append(w.msgs, s)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// violations returns any recorded message mentioning a CSP violation.
|
||||
func (w *consoleWatch) violations() []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
var out []string
|
||||
for _, m := range w.msgs {
|
||||
if strings.Contains(strings.ToLower(m), "content security policy") {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// assertClean fails if any CSP violation was reported.
|
||||
func (w *consoleWatch) assertClean(t *testing.T) {
|
||||
t.Helper()
|
||||
if v := w.violations(); len(v) > 0 {
|
||||
t.Fatalf("CSP violation(s) in browser console:\n%s", strings.Join(v, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// startBrowser connects to the headless-shell container and returns a chromedp
|
||||
// context, a cancel func, and a console watcher. It skips the test if the
|
||||
// container isn't reachable.
|
||||
func startBrowser(t *testing.T) (context.Context, context.CancelFunc, *consoleWatch) {
|
||||
t.Helper()
|
||||
wsURL := devtoolsWebSocket(t)
|
||||
|
||||
allocCtx, cancelAlloc := chromedp.NewRemoteAllocator(context.Background(), wsURL)
|
||||
ctx, cancelCtx := chromedp.NewContext(allocCtx)
|
||||
|
||||
watch := &consoleWatch{}
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch e := ev.(type) {
|
||||
case *cdplog.EventEntryAdded:
|
||||
// CSP violations arrive here with source="security", level="error".
|
||||
if e.Entry != nil {
|
||||
watch.add(e.Entry.Text)
|
||||
}
|
||||
case *runtime.EventConsoleAPICalled:
|
||||
if e.Type == "error" {
|
||||
var parts []string
|
||||
for _, a := range e.Args {
|
||||
parts = append(parts, string(a.Value))
|
||||
}
|
||||
watch.add(strings.Join(parts, " "))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
cancel := func() { cancelCtx(); cancelAlloc() }
|
||||
return ctx, cancel, watch
|
||||
}
|
||||
|
||||
// devtoolsWebSocket asks the headless-shell container for its browser-level
|
||||
// DevTools websocket URL, skipping the test if the container is down.
|
||||
func devtoolsWebSocket(t *testing.T) string {
|
||||
t.Helper()
|
||||
base := devtoolsBase()
|
||||
c := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := c.Get(base + "/json/version")
|
||||
if err != nil {
|
||||
t.Skipf("headless-shell not reachable at %s (run `mise run e2e`): %v", base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var v struct {
|
||||
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil || v.WebSocketDebuggerURL == "" {
|
||||
t.Skipf("headless-shell /json/version malformed: %v", err)
|
||||
}
|
||||
// The reported ws URL carries the browser's own view of its host (e.g.
|
||||
// 127.0.0.1:9222); rewrite it to the host we actually reached it on so the
|
||||
// same code works locally (published port) and in CI (service hostname).
|
||||
baseURL, err := url.Parse(base)
|
||||
if err != nil {
|
||||
t.Fatalf("bad E2E_DEVTOOLS_URL %q: %v", base, err)
|
||||
}
|
||||
wsu, err := url.Parse(v.WebSocketDebuggerURL)
|
||||
if err != nil {
|
||||
t.Fatalf("bad webSocketDebuggerUrl %q: %v", v.WebSocketDebuggerURL, err)
|
||||
}
|
||||
wsu.Host = baseURL.Host
|
||||
return wsu.String()
|
||||
}
|
||||
|
||||
// setSessionCookie injects the app session cookie for the browser-facing host,
|
||||
// so the browser is authenticated before it navigates.
|
||||
func setSessionCookie(c *http.Cookie) chromedp.Action {
|
||||
return chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
return network.SetCookie(c.Name, c.Value).
|
||||
WithDomain(browserHost()).
|
||||
WithPath("/").
|
||||
WithHTTPOnly(true).
|
||||
Do(ctx)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build browser
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
cdplog "github.com/chromedp/cdproto/log"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
// TestE2ESmokeMaintenancePage is the harness "first light": it proves the whole
|
||||
// browser-test plumbing works end to end — a headless Chrome in a container
|
||||
// authenticates via an injected session cookie, reaches the app on the Docker
|
||||
// host, renders a protected page, and runs it under the strict CSP with no
|
||||
// violations. It asserts nothing about any specific bug.
|
||||
func TestE2ESmokeMaintenancePage(t *testing.T) {
|
||||
srv := newE2EServer(t)
|
||||
user, cookie := srv.login(t, "smokeuser")
|
||||
rep := srv.newRepeater(t, user.ID, "Smoke Repeater")
|
||||
|
||||
bctx, cancel, watch := startBrowser(t)
|
||||
defer cancel()
|
||||
|
||||
url := srv.browserURL + "/repeaters/" + rep.PublicID + "/maintenance"
|
||||
var formHTML string
|
||||
if err := chromedp.Run(bctx,
|
||||
network.Enable(),
|
||||
cdplog.Enable(),
|
||||
setSessionCookie(cookie),
|
||||
chromedp.Navigate(url),
|
||||
chromedp.WaitVisible(`form[action$="/maintenance"]`, chromedp.ByQuery),
|
||||
chromedp.OuterHTML(`form[action$="/maintenance"]`, &formHTML, chromedp.ByQuery),
|
||||
); err != nil {
|
||||
t.Fatalf("browser run against %s: %v", url, err)
|
||||
}
|
||||
|
||||
if !strings.Contains(formHTML, "Add entry") {
|
||||
t.Fatalf("maintenance page did not render the Add-entry form; got:\n%s", formHTML)
|
||||
}
|
||||
watch.assertClean(t)
|
||||
}
|
||||
Reference in new issue
Block a user