Get all domains working in e2e tests

This commit is contained in:
Jonathon Leight
2026-07-05 13:40:56 -04:00
parent aaa16c2e1c
commit d35f5dbb97
12 changed files with 73 additions and 79 deletions
+3 -1
View File
@@ -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/
+10
View File
@@ -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:
+48 -66
View File
@@ -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.<browserHost>:PORT the product surface
authURL string // https://auth.<browserHost>:PORT — sign-in + account
rootURL string // https://root.<browserHost>: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 *.<browserHost> <browserHost>) 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.<browserHost>), 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
})
}
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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(),
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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),