From 0b54870fa1b213f620ae467164e2ff8c736f1843 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Thu, 2 Jul 2026 12:47:03 -0400 Subject: [PATCH] Set up e2e test harness --- go.mod | 7 + go.sum | 19 +++ internal/e2e/harness_test.go | 312 +++++++++++++++++++++++++++++++++++ internal/e2e/smoke_test.go | 44 +++++ 4 files changed, 382 insertions(+) create mode 100644 internal/e2e/harness_test.go create mode 100644 internal/e2e/smoke_test.go diff --git a/go.mod b/go.mod index 1fb0eec..22aa98c 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 679f87f..5035420 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/e2e/harness_test.go b/internal/e2e/harness_test.go new file mode 100644 index 0000000..d2c99ee --- /dev/null +++ b/internal/e2e/harness_test.go @@ -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 _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) + }) +} diff --git a/internal/e2e/smoke_test.go b/internal/e2e/smoke_test.go new file mode 100644 index 0000000..97a2775 --- /dev/null +++ b/internal/e2e/smoke_test.go @@ -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) +}