auth-ui: add e2e tests

Change-Id: Ic8f2f9e032d24eed2d4fd824dcfc26c59d7d915e
diff --git a/core/auth/ui/e2e/browser.go b/core/auth/ui/e2e/browser.go
new file mode 100644
index 0000000..19949f5
--- /dev/null
+++ b/core/auth/ui/e2e/browser.go
@@ -0,0 +1,444 @@
+package e2e
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+
+	playwright "github.com/mxschmitt/playwright-go"
+)
+
+type browserRuntime struct {
+	Playwright *playwright.Playwright
+	Browser    playwright.Browser
+	mu         sync.Mutex
+	closed     bool
+}
+
+func startBrowserRuntime() (*browserRuntime, error) {
+	return startBrowserRuntimeOwned(nil)
+}
+
+func startBrowserRuntimeOwned(owner *suiteLifecycle) (*browserRuntime, error) {
+	runtime := &browserRuntime{}
+	if owner != nil && !owner.setBrowser(runtime) {
+		return nil, fmt.Errorf("E2E watchdog fired before browser ownership registration")
+	}
+	pw, err := playwright.Run()
+	if err != nil {
+		return runtime, fmt.Errorf("start Playwright %s: %w; run `make install-e2e-browser` (go run github.com/mxschmitt/playwright-go/cmd/playwright@%s install chromium)", playwrightVersion, err, playwrightVersion)
+	}
+	runtime.mu.Lock()
+	if runtime.closed {
+		runtime.mu.Unlock()
+		_ = pw.Stop()
+		return runtime, fmt.Errorf("E2E cleanup started while launching Playwright")
+	}
+	runtime.Playwright = pw
+	runtime.mu.Unlock()
+	browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{Headless: playwright.Bool(true)})
+	if err != nil {
+		_ = runtime.Close()
+		return runtime, fmt.Errorf("launch Playwright-managed Chromium: %w; run `make install-e2e-browser`", err)
+	}
+	runtime.mu.Lock()
+	if runtime.closed {
+		runtime.mu.Unlock()
+		_ = browser.Close()
+		return runtime, fmt.Errorf("E2E cleanup started while launching Chromium")
+	}
+	runtime.Browser = browser
+	runtime.mu.Unlock()
+	if got := browser.Version(); got != chromiumVersion {
+		_ = runtime.Close()
+		return runtime, fmt.Errorf("unexpected managed Chromium version %q, want %s (revision %s, Playwright CLI %s)", got, chromiumVersion, chromiumRevision, playwrightCLIVersion)
+	}
+	return runtime, nil
+}
+
+func (r *browserRuntime) Close() error {
+	if r == nil {
+		return nil
+	}
+	r.mu.Lock()
+	if r.closed {
+		r.mu.Unlock()
+		return nil
+	}
+	r.closed = true
+	browser, pw := r.Browser, r.Playwright
+	r.mu.Unlock()
+	var errs []string
+	if browser != nil {
+		if err := browser.Close(); err != nil {
+			errs = append(errs, err.Error())
+		}
+	}
+	if pw != nil {
+		if err := pw.Stop(); err != nil {
+			errs = append(errs, err.Error())
+		}
+	}
+	if len(errs) > 0 {
+		return fmt.Errorf("close browser runtime: %s", strings.Join(errs, "; "))
+	}
+	return nil
+}
+
+type testReporter interface {
+	Name() string
+	Failed() bool
+	Errorf(string, ...any)
+	Cleanup(func())
+}
+
+type browserSession struct {
+	Page           playwright.Page
+	Context        playwright.BrowserContext
+	t              testReporter
+	dir            string
+	video          playwright.Video
+	started        time.Time
+	screenshots    []string
+	checkpoint     int
+	finalize       sync.Once
+	lifecycleMu    sync.Mutex
+	forcedFailure  bool
+	registry       *sessionRegistry
+	browserVer     string
+	blockedMu      sync.Mutex
+	blocked        []string
+	requestsMu     sync.Mutex
+	requests       []requestMetadata
+	tracingStarted bool
+	screenshotOp   func(string) error
+	stopTraceOp    func(string) error
+	closeContextOp func() error
+	saveVideoOp    func(string) error
+	finalURLOp     func() string
+}
+
+func newBrowserSession(t testReporter, browser playwright.Browser, root string, allowedOrigins []string) (*browserSession, error) {
+	session, err := newBrowserSessionOwner(t, root, browser.Version())
+	if err != nil {
+		return nil, err
+	}
+	// Register while holding the construction/finalization lock. A watchdog
+	// either snapshots this ownership and waits here, or rejects construction
+	// before any external Playwright context exists.
+	session.lifecycleMu.Lock()
+	if activeSuiteLifecycle != nil {
+		session.registry = activeSuiteLifecycle.sessions
+		if !session.registry.add(session) {
+			session.lifecycleMu.Unlock()
+			_ = session.finalizeAfterTimeout()
+			return nil, fmt.Errorf("create browser session after E2E watchdog cleanup started")
+		}
+	}
+	defer session.lifecycleMu.Unlock()
+	videoDir := filepath.Join(session.dir, ".video")
+	context, err := browser.NewContext(playwright.BrowserNewContextOptions{
+		Viewport:       &playwright.Size{Width: 1280, Height: 720},
+		RecordVideo:    &playwright.RecordVideo{Dir: playwright.String(videoDir), Size: &playwright.Size{Width: 1280, Height: 720}},
+		ServiceWorkers: playwright.ServiceWorkerPolicyBlock,
+	})
+	if err != nil {
+		return nil, err
+	}
+	session.Context = context
+	if err := context.Tracing().Start(playwright.TracingStartOptions{Screenshots: playwright.Bool(true), Snapshots: playwright.Bool(true), Sources: playwright.Bool(true)}); err != nil {
+		return nil, err
+	}
+	session.tracingStarted = true
+	page, err := context.NewPage()
+	if err != nil {
+		return nil, err
+	}
+	page.SetDefaultTimeout(15_000)
+	page.SetDefaultNavigationTimeout(30_000)
+	page.OnRequest(func(request playwright.Request) {
+		session.recordRequest(request.Method(), 0, request.URL())
+	})
+	page.OnResponse(func(response playwright.Response) {
+		session.recordRequest(response.Request().Method(), response.Status(), response.Request().URL())
+	})
+	s := session
+	s.Page = page
+	s.video = page.Video()
+	policy := newRoutePolicy(allowedOrigins)
+	if err := context.Route("**/*", func(route playwright.Route) {
+		if policy(route.Request().URL()) {
+			_ = route.Continue()
+		} else {
+			session.recordBlocked(route.Request().URL())
+			_ = route.Abort("blockedbyclient")
+		}
+	}); err != nil {
+		return nil, err
+	}
+	if err := s.screenshot("00-initial.png"); err != nil {
+		return nil, err
+	}
+	return s, nil
+}
+
+func newBrowserSessionOwner(t testReporter, root, browserVersion string) (*browserSession, error) {
+	dir := filepath.Join(root, sanitizeName(t.Name()))
+	if err := os.MkdirAll(filepath.Join(dir, "screenshots"), 0o700); err != nil {
+		return nil, err
+	}
+	if err := os.MkdirAll(filepath.Join(dir, ".video"), 0o700); err != nil {
+		return nil, err
+	}
+	session := &browserSession{t: t, dir: dir, started: time.Now().UTC(), browserVer: browserVersion}
+	session.installDefaultArtifactOps()
+	// Own metadata and all available partial artifacts before Playwright context
+	// construction. If Playwright cannot create a context/page/trace, cleanup
+	// records the failure without pretending the unavailable artifacts succeeded.
+	t.Cleanup(func() {
+		if err := session.Finalize(); err != nil {
+			t.Errorf("finalize browser artifacts: %v", err)
+		}
+	})
+	return session, nil
+}
+
+func newRoutePolicy(allowedOrigins []string) func(string) bool {
+	allowed := make(map[string]struct{}, len(allowedOrigins))
+	for _, origin := range allowedOrigins {
+		u, err := url.Parse(origin)
+		if err == nil && u.Scheme == "http" && u.Hostname() == "127.0.0.1" && u.Port() != "" && u.Path == "" {
+			allowed[u.Scheme+"://"+u.Host] = struct{}{}
+		}
+	}
+	return func(raw string) bool {
+		u, err := url.Parse(raw)
+		if err != nil || u.Scheme != "http" || u.Hostname() != "127.0.0.1" || u.Port() == "" || u.User != nil {
+			return false
+		}
+		_, ok := allowed[u.Scheme+"://"+u.Host]
+		return ok
+	}
+}
+
+type requestMetadata struct {
+	Method string `json:"method"`
+	Status int    `json:"status,omitempty"`
+	Origin string `json:"origin"`
+	Path   string `json:"path"`
+}
+
+func sanitizedRequest(method string, status int, raw string) (requestMetadata, bool) {
+	u, err := url.Parse(raw)
+	if err != nil || u.Scheme == "" || u.Host == "" {
+		return requestMetadata{}, false
+	}
+	return requestMetadata{Method: method, Status: status, Origin: u.Scheme + "://" + u.Host, Path: u.EscapedPath()}, true
+}
+
+func (s *browserSession) recordRequest(method string, status int, raw string) {
+	metadata, ok := sanitizedRequest(method, status, raw)
+	if !ok {
+		return
+	}
+	s.requestsMu.Lock()
+	s.requests = append(s.requests, metadata)
+	s.requestsMu.Unlock()
+}
+
+func (s *browserSession) RequestMetadata() []requestMetadata {
+	s.requestsMu.Lock()
+	defer s.requestsMu.Unlock()
+	return append([]requestMetadata(nil), s.requests...)
+}
+
+func (s *browserSession) recordBlocked(raw string) {
+	u, err := url.Parse(raw)
+	if err != nil {
+		return
+	}
+	s.blockedMu.Lock()
+	s.blocked = append(s.blocked, u.Scheme+"://"+u.Host+u.Path)
+	s.blockedMu.Unlock()
+}
+
+func (s *browserSession) BlockedRequests() []string {
+	s.blockedMu.Lock()
+	defer s.blockedMu.Unlock()
+	return sortedStrings(s.blocked)
+}
+
+func (s *browserSession) Checkpoint(name string) error {
+	s.lifecycleMu.Lock()
+	defer s.lifecycleMu.Unlock()
+	s.checkpoint++
+	return s.screenshot(fmt.Sprintf("%02d-%s.png", s.checkpoint, sanitizeName(name)))
+}
+
+func (s *browserSession) installDefaultArtifactOps() {
+	s.screenshotOp = func(path string) error {
+		if s.Page == nil {
+			return fmt.Errorf("page unavailable")
+		}
+		_, err := s.Page.Screenshot(playwright.PageScreenshotOptions{Path: playwright.String(path), FullPage: playwright.Bool(true)})
+		return err
+	}
+	s.stopTraceOp = func(path string) error {
+		if s.Context == nil || !s.tracingStarted {
+			return fmt.Errorf("tracing unavailable")
+		}
+		return s.Context.Tracing().Stop(path)
+	}
+	s.closeContextOp = func() error {
+		if s.Context == nil {
+			return nil
+		}
+		return s.Context.Close()
+	}
+	s.saveVideoOp = func(path string) error {
+		if s.video == nil {
+			return fmt.Errorf("video unavailable")
+		}
+		return s.video.SaveAs(path)
+	}
+	s.finalURLOp = func() string {
+		if s.Page == nil {
+			return ""
+		}
+		return s.Page.URL()
+	}
+}
+
+func (s *browserSession) screenshot(name string) error {
+	path := filepath.Join(s.dir, "screenshots", name)
+	if err := s.screenshotOp(path); err != nil {
+		return err
+	}
+	s.screenshots = append(s.screenshots, name)
+	return nil
+}
+
+func (s *browserSession) Finalize() error {
+	if s.registry != nil {
+		return s.registry.finalizeNormal(s)
+	}
+	return s.finalizeClaimed(false)
+}
+
+func (s *browserSession) finalizeAfterTimeout() error {
+	return s.finalizeClaimed(true)
+}
+
+func (s *browserSession) finalizeClaimed(forcedFailure bool) error {
+	s.lifecycleMu.Lock()
+	defer s.lifecycleMu.Unlock()
+	if forcedFailure {
+		s.forcedFailure = true
+	}
+	var finalErr error
+	s.finalize.Do(func() {
+		var errs []string
+		if err := s.screenshot("99-final.png"); err != nil {
+			errs = append(errs, "final screenshot: "+err.Error())
+		}
+		if s.t.Failed() || s.forcedFailure {
+			if err := s.screenshot("failure.png"); err != nil {
+				errs = append(errs, "failure screenshot: "+err.Error())
+			}
+		}
+		if err := s.stopTraceOp(filepath.Join(s.dir, "trace.zip")); err != nil {
+			errs = append(errs, "trace: "+err.Error())
+		}
+		finalURL := sanitizeFinalURL(s.finalURLOp())
+		if err := s.closeContextOp(); err != nil {
+			errs = append(errs, "context: "+err.Error())
+		}
+		if err := s.saveVideoOp(filepath.Join(s.dir, "video.webm")); err != nil {
+			errs = append(errs, "video: "+err.Error())
+		} else {
+			_ = os.RemoveAll(filepath.Join(s.dir, ".video"))
+		}
+		outcome := "passed"
+		if s.t.Failed() || s.forcedFailure || len(errs) > 0 {
+			outcome = "failed"
+		}
+		metadata := sessionMetadata{TestName: s.t.Name(), StartedAt: s.started, FinishedAt: time.Now().UTC(), Outcome: outcome, BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: s.browserVer, Screenshots: s.screenshots, FinalURL: finalURL}
+		data, err := json.MarshalIndent(metadata, "", "  ")
+		if err == nil {
+			err = os.WriteFile(filepath.Join(s.dir, "session.json"), append(data, '\n'), 0o600)
+		}
+		if err != nil {
+			errs = append(errs, "metadata: "+err.Error())
+		}
+		if len(errs) > 0 {
+			finalErr = fmt.Errorf("%s", strings.Join(errs, "; "))
+		}
+	})
+	return finalErr
+}
+
+func artifactOutcomeExpectation(failedBeforeFinalize, failedAfterFinalize bool) (outcome string, requireFailureScreenshot bool) {
+	if failedBeforeFinalize {
+		return "failed", true
+	}
+	if failedAfterFinalize {
+		return "failed", false
+	}
+	return "passed", false
+}
+
+const expectedExternalFontRequest = "https://cdnjs.cloudflare.com/ajax/libs/hack-font/3.3.0/web/hack.min.css"
+
+func isExpectedBlockedBrowserRequest(request string) bool {
+	return request == expectedExternalFontRequest
+}
+
+type sessionMetadata struct {
+	TestName         string    `json:"test_name"`
+	StartedAt        time.Time `json:"started_at"`
+	FinishedAt       time.Time `json:"finished_at"`
+	Outcome          string    `json:"outcome"`
+	BindingVersion   string    `json:"binding_version"`
+	CLIVersion       string    `json:"playwright_cli_version"`
+	ChromiumRevision string    `json:"chromium_revision"`
+	BrowserVersion   string    `json:"browser_version"`
+	Screenshots      []string  `json:"screenshots"`
+	FinalURL         string    `json:"final_url"`
+}
+
+var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
+
+func sanitizeName(name string) string {
+	name = strings.Trim(unsafeName.ReplaceAllString(name, "-"), "-.")
+	if name == "" {
+		return "unnamed"
+	}
+	if len(name) > 100 {
+		name = name[:100]
+	}
+	return name
+}
+
+func sanitizeFinalURL(raw string) string {
+	u, err := url.Parse(raw)
+	if err != nil || u.Scheme == "" || u.Host == "" {
+		return ""
+	}
+	u.RawQuery = ""
+	u.Fragment = ""
+	u.User = nil
+	return u.String()
+}
+
+func sortedStrings(values []string) []string {
+	out := append([]string(nil), values...)
+	sort.Strings(out)
+	return out
+}