From d35f5dbb97e5490cc2f110a869315fcb9c1fdef2 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sun, 5 Jul 2026 13:40:56 -0400 Subject: [PATCH] Get all domains working in e2e tests --- .config/mise/config.toml | 4 +- .woodpecker/e2e.yaml | 10 +++ internal/e2e/harness_test.go | 114 +++++++++++--------------- internal/e2e/limit_commands_test.go | 2 +- internal/e2e/logout_test.go | 2 +- internal/e2e/maintenance_test.go | 2 +- internal/e2e/org_actions_test.go | 2 +- internal/e2e/repeaters_filter_test.go | 2 +- internal/e2e/smoke_test.go | 2 +- internal/e2e/user_links_test.go | 4 +- internal/e2e/user_public_test.go | 4 +- internal/e2e/webauthn_test.go | 4 +- 12 files changed, 73 insertions(+), 79 deletions(-) diff --git a/.config/mise/config.toml b/.config/mise/config.toml index eb9703b..076d740 100644 --- a/.config/mise/config.toml +++ b/.config/mise/config.toml @@ -24,7 +24,9 @@ run = "go run ./cmd/meshtender --reset" [tasks.e2e] run = """ docker run -d --rm --name mt-headless -p 9222:9222 \ - --add-host=host.docker.internal:host-gateway chromedp/headless-shell:latest >/dev/null + --add-host=host.docker.internal:host-gateway \ + --entrypoint bash chromedp/headless-shell:latest -c \ + 'socat TCP4-LISTEN:9222,fork TCP4:127.0.0.1:9223 & exec /headless-shell/headless-shell --no-sandbox --use-gl=angle --use-angle=swiftshader --remote-debugging-address=0.0.0.0 --remote-debugging-port=9223 "--host-resolver-rules=MAP *.host.docker.internal host.docker.internal"' >/dev/null trap 'docker stop mt-headless >/dev/null' EXIT until curl -sf http://127.0.0.1:9222/json/version >/dev/null; do sleep 0.5; done go test -tags browser ./internal/e2e/ diff --git a/.woodpecker/e2e.yaml b/.woodpecker/e2e.yaml index 2029f45..0372617 100644 --- a/.woodpecker/e2e.yaml +++ b/.woodpecker/e2e.yaml @@ -27,6 +27,16 @@ services: POSTGRES_DB: meshtender_test headless: image: chromedp/headless-shell:latest + # Map every surface subdomain (app./auth./root.e2e) to this step's server so + # the browser can reach all three surfaces at once; the Dispatcher then routes + # by Host header. Mirrors the --host-resolver-rules in the `e2e` mise task, + # with the step name (E2E_BROWSER_HOST) as the map target. run.sh forwards + # args via unquoted $@ (splitting the flag on its space), so we replicate it + # here to pass the flag quoted while keeping its socat 9222->9223 forward. + entrypoint: + - bash + - -c + - 'socat TCP4-LISTEN:9222,fork TCP4:127.0.0.1:9223 & exec /headless-shell/headless-shell --no-sandbox --use-gl=angle --use-angle=swiftshader --remote-debugging-address=0.0.0.0 --remote-debugging-port=9223 "--host-resolver-rules=MAP *.e2e e2e"' steps: e2e: diff --git a/internal/e2e/harness_test.go b/internal/e2e/harness_test.go index f40bffa..4c61d57 100644 --- a/internal/e2e/harness_test.go +++ b/internal/e2e/harness_test.go @@ -88,61 +88,39 @@ func envOr(key, def string) string { 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). +// e2eServer is a running app server wired to a fresh test DB. hostURL reaches it +// from this process (the seed-session Go client); appURL/authURL/rootURL are the +// three surfaces as the browser addresses them — all three resolve back here +// because the container maps every *.browserHost() name to the reachable host +// (see the --host-resolver-rules flag in the `e2e` mise task / CI service). type e2eServer struct { - store *store.Store - ctx context.Context - ts *httptest.Server - hostURL string // https://127.0.0.1:PORT — reachable from this process - browserURL string // https://host.docker.internal:PORT — reachable from the container + store *store.Store + ctx context.Context + ts *httptest.Server + hostURL string // https://127.0.0.1:PORT — reachable from this process + appURL string // https://app.:PORT — the product surface + authURL string // https://auth.:PORT — sign-in + account + rootURL string // https://root.:PORT — public discovery } -// hostLayout names the three surfaces for a test server. The surface whose name -// equals browserHost() is the one the browser can actually reach (that's the -// only alias the container resolves back to this process); everything else is -// reachable only host-side via 127.0.0.1 (the Dispatcher's default → app). -type hostLayout struct{ app, auth, root string } - -// defaultHosts puts the APP surface on the browser-reachable host — the common -// case, since most browser tests drive app pages. -func defaultHosts() hostLayout { - return hostLayout{app: browserHost(), auth: "auth." + browserHost(), root: "root." + browserHost()} -} - -// authReachableHosts puts the AUTH surface on the browser-reachable host so a -// browser test can drive auth-host pages (e.g. /account). The app surface moves -// to a name nothing navigates; login()'s /session/callback handoff still works -// because it runs host-side over 127.0.0.1, which the Dispatcher routes to the -// app surface by default. -func authReachableHosts() hostLayout { - return hostLayout{app: "app." + browserHost(), auth: browserHost(), root: "root." + browserHost()} -} - -// rootReachableHosts puts the ROOT (public marketing) surface on the -// browser-reachable host so a browser test can drive public pages like the -// /u/{username} profile. -func rootReachableHosts() hostLayout { - return hostLayout{app: "app." + browserHost(), auth: "auth." + browserHost(), root: browserHost()} -} +// Surface hostnames. All three are subdomains of browserHost() so a single +// host-resolver rule (MAP *. ) makes every surface +// reachable from the browser at once — the Dispatcher then routes by Host header. +func appHost() string { return "app." + browserHost() } +func authHost() string { return "auth." + browserHost() } +func rootHost() string { return "root." + browserHost() } // newE2EServer stands up the app over HTTPS (a self-signed cert) on a 0.0.0.0 -// listener so the browser container can connect back to it, and returns both -// address forms. An optional hostLayout selects which surface the browser can -// reach (defaults to the app surface). +// listener so the browser container can connect back to it, across all three +// surfaces at once. // // The suite is HTTPS throughout for two reasons: WebAuthn ceremonies require a // secure context (a plain-HTTP non-localhost origin is not one), and serving TLS // exercises the same Secure/__Host- cookie path production uses. The browser // trusts the self-signed cert via Security.setIgnoreCertificateErrors (applied // for every test in startBrowser); the host-side client skips verification too. -func newE2EServer(t *testing.T, layout ...hostLayout) *e2eServer { +func newE2EServer(t *testing.T) *e2eServer { t.Helper() - hosts := defaultHosts() - if len(layout) > 0 { - hosts = layout[0] - } ctx := context.Background() st, err := store.New(ctx, testdb.Fresh(t, migrate)) if err != nil { @@ -162,23 +140,20 @@ func newE2EServer(t *testing.T, layout ...hostLayout) *e2eServer { port := ln.Addr().(*net.TCPAddr).Port origin := func(h string) string { return fmt.Sprintf("https://%s:%d", h, port) } - // The server runs across three distinct hosts so the Dispatcher can tell the - // surfaces apart. The browser can navigate only the one named browserHost() - // (host.docker.internal) — the sole alias the container resolves back here; the - // layout decides which surface that is. The RP ID is browserHost(): a WebAuthn - // ceremony's origin host is that name (or a subdomain of it), so it's a valid - // RP ID, and every surface origin is allowed. + // The RP ID is browserHost(): it's a registrable-domain suffix of every + // surface host (app./auth./root.), so it's a valid RP ID for a + // ceremony run from any of them, and every surface origin is allowed. authSvc, err := auth.New(st, st.Pool(), auth.Config{ RPID: browserHost(), RPDisplayName: "test", - RPOrigins: []string{origin(hosts.app), origin(hosts.auth), origin(hosts.root)}, - AppHost: hosts.app, AuthHost: hosts.auth, RootHost: hosts.root, + RPOrigins: []string{origin(appHost()), origin(authHost()), origin(rootHost())}, + AppHost: appHost(), AuthHost: authHost(), RootHost: rootHost(), Secure: true, }) if err != nil { t.Fatalf("auth: %v", err) } srv, err := core.NewServer(st, authSvc, idSvc, &config.Config{ - PrimaryHost: hosts.app, AuthHost: hosts.auth, RootHost: hosts.root, Secure: true, + PrimaryHost: appHost(), AuthHost: authHost(), RootHost: rootHost(), Secure: true, }) if err != nil { t.Fatalf("server: %v", err) @@ -192,11 +167,13 @@ func newE2EServer(t *testing.T, layout ...hostLayout) *e2eServer { ts.URL = fmt.Sprintf("https://127.0.0.1:%d", port) return &e2eServer{ - store: st, - ctx: ctx, - ts: ts, - hostURL: ts.URL, - browserURL: fmt.Sprintf("https://%s:%d", browserHost(), port), + store: st, + ctx: ctx, + ts: ts, + hostURL: ts.URL, + appURL: origin(appHost()), + authURL: origin(authHost()), + rootURL: origin(rootHost()), } } @@ -376,14 +353,19 @@ func devtoolsWebSocket(t *testing.T) string { // so the browser is authenticated before it navigates. func setSessionCookie(c *http.Cookie) chromedp.Action { return chromedp.ActionFunc(func(ctx context.Context) error { - // __Host- prefixed cookies must be host-only (no Domain), Secure, Path=/. - // WithURL scopes the cookie to browserHost as host-only (cookies ignore - // port, so the scheme+host is enough). - return network.SetCookie(c.Name, c.Value). - WithURL("https://" + browserHost()). - WithPath("/"). - WithHTTPOnly(true). - WithSecure(true). - Do(ctx) + // The session token is store-global; inject it host-only on each surface + // (a __Host- cookie can't carry a Domain, and cookies ignore port, so the + // scheme+host is enough) so whichever surface a test drives is authenticated. + for _, h := range []string{appHost(), authHost(), rootHost()} { + if err := network.SetCookie(c.Name, c.Value). + WithURL("https://" + h). + WithPath("/"). + WithHTTPOnly(true). + WithSecure(true). + Do(ctx); err != nil { + return err + } + } + return nil }) } diff --git a/internal/e2e/limit_commands_test.go b/internal/e2e/limit_commands_test.go index 5885e69..3935285 100644 --- a/internal/e2e/limit_commands_test.go +++ b/internal/e2e/limit_commands_test.go @@ -29,7 +29,7 @@ func TestE2ELimitCommandsSelectAll(t *testing.T) { bctx, cancel, watch := startBrowser(t) defer cancel() - url := srv.browserURL + "/orgs/" + org.Slug + "/my-commands" + url := srv.appURL + "/orgs/" + org.Slug + "/my-commands" // Count of checkboxes in each section, and how many are checked. Sections are // [data-check-scope] cards; each has its own Select all / Select none buttons. diff --git a/internal/e2e/logout_test.go b/internal/e2e/logout_test.go index da9a3bd..1bb80f9 100644 --- a/internal/e2e/logout_test.go +++ b/internal/e2e/logout_test.go @@ -25,7 +25,7 @@ func TestE2ELogout(t *testing.T) { bctx, cancel, watch := startBrowser(t) defer cancel() - dash := srv.browserURL + "/" + dash := srv.appURL + "/" // Log out, then probe a protected page — both with redirect:'manual' so a // sign-in bounce surfaces as an opaqueredirect instead of being followed. diff --git a/internal/e2e/maintenance_test.go b/internal/e2e/maintenance_test.go index fe67267..e17cd8d 100644 --- a/internal/e2e/maintenance_test.go +++ b/internal/e2e/maintenance_test.go @@ -38,7 +38,7 @@ func TestE2EMaintenanceShowsCurrentAuthorName(t *testing.T) { bctx, cancel, watch := startBrowser(t) defer cancel() - url := srv.browserURL + "/repeaters/" + rep.PublicID + "/maintenance" + url := srv.appURL + "/repeaters/" + rep.PublicID + "/maintenance" var authorText string if err := chromedp.Run(bctx, network.Enable(), diff --git a/internal/e2e/org_actions_test.go b/internal/e2e/org_actions_test.go index f0ac05d..0978ec3 100644 --- a/internal/e2e/org_actions_test.go +++ b/internal/e2e/org_actions_test.go @@ -30,7 +30,7 @@ func TestE2EOrgActionsMenuLimitCommands(t *testing.T) { bctx, cancel, watch := startBrowser(t) defer cancel() - orgURL := srv.browserURL + "/orgs/" + org.Slug + orgURL := srv.appURL + "/orgs/" + org.Slug var landedURL string if err := chromedp.Run(bctx, network.Enable(), diff --git a/internal/e2e/repeaters_filter_test.go b/internal/e2e/repeaters_filter_test.go index 5b3bfd3..399d513 100644 --- a/internal/e2e/repeaters_filter_test.go +++ b/internal/e2e/repeaters_filter_test.go @@ -66,7 +66,7 @@ func TestE2ERepeatersFilter(t *testing.T) { network.Enable(), cdplog.Enable(), setSessionCookie(cookie), - chromedp.Navigate(srv.browserURL+"/repeaters"), + chromedp.Navigate(srv.appURL+"/repeaters"), chromedp.WaitVisible(`#rep-search`, chromedp.ByID), chromedp.Evaluate(countVisible, &initial), ); err != nil { diff --git a/internal/e2e/smoke_test.go b/internal/e2e/smoke_test.go index 97a2775..aaeef33 100644 --- a/internal/e2e/smoke_test.go +++ b/internal/e2e/smoke_test.go @@ -24,7 +24,7 @@ func TestE2ESmokeMaintenancePage(t *testing.T) { bctx, cancel, watch := startBrowser(t) defer cancel() - url := srv.browserURL + "/repeaters/" + rep.PublicID + "/maintenance" + url := srv.appURL + "/repeaters/" + rep.PublicID + "/maintenance" var formHTML string if err := chromedp.Run(bctx, network.Enable(), diff --git a/internal/e2e/user_links_test.go b/internal/e2e/user_links_test.go index b1210ba..ab692aa 100644 --- a/internal/e2e/user_links_test.go +++ b/internal/e2e/user_links_test.go @@ -20,13 +20,13 @@ import ( func TestE2EUserLinksClientValidation(t *testing.T) { // The account page lives on the auth surface, so put that on the // browser-reachable host for this test. - srv := newE2EServer(t, authReachableHosts()) + srv := newE2EServer(t) user, cookie := srv.login(t, "linkse2e") bctx, cancel, watch := startBrowser(t) defer cancel() - accountURL := srv.browserURL + "/account" + accountURL := srv.authURL + "/account" // Phase 1: an invalid email row must block the submit client-side. We assert // the inline error appears and that we never navigated (no ?ok, no server diff --git a/internal/e2e/user_public_test.go b/internal/e2e/user_public_test.go index d05c55f..70f920f 100644 --- a/internal/e2e/user_public_test.go +++ b/internal/e2e/user_public_test.go @@ -22,7 +22,7 @@ import ( // strict CSP. func TestE2EUserPublicLinks(t *testing.T) { // The public profile is served by the root (marketing) surface. - srv := newE2EServer(t, rootReachableHosts()) + srv := newE2EServer(t) u, err := srv.store.CreateUser(srv.ctx, "profileuser", "") if err != nil { @@ -44,7 +44,7 @@ func TestE2EUserPublicLinks(t *testing.T) { bctx, cancel, watch := startBrowser(t) defer cancel() - profileURL := srv.browserURL + "/u/profileuser" + profileURL := srv.rootURL + "/u/profileuser" var listText, meshCodeText string var qrShown, titlesShown bool if err := chromedp.Run(bctx, diff --git a/internal/e2e/webauthn_test.go b/internal/e2e/webauthn_test.go index 5a1bf8d..1994fd3 100644 --- a/internal/e2e/webauthn_test.go +++ b/internal/e2e/webauthn_test.go @@ -62,7 +62,7 @@ func waitForUser(t *testing.T, e *e2eServer, username string) *store.User { // account get written (the deferred-creation flow). This is the browser-level // proof of item 3 that the Go tests can't give (they can't complete a ceremony). func TestPasskeySignupCeremony(t *testing.T) { - e := newE2EServer(t, authReachableHosts()) + e := newE2EServer(t) ctx, cancel, watch := startBrowser(t) defer cancel() @@ -77,7 +77,7 @@ func TestPasskeySignupCeremony(t *testing.T) { } if err := chromedp.Run(ctx, - chromedp.Navigate(e.browserURL+"/signup"), + chromedp.Navigate(e.authURL+"/signup"), chromedp.WaitVisible("#signup-passkey-btn", chromedp.ByID), chromedp.SendKeys("#username", username, chromedp.ByID), chromedp.Click("#signup-passkey-btn", chromedp.ByID),