auth-ui: add e2e tests
Change-Id: Ic8f2f9e032d24eed2d4fd824dcfc26c59d7d915e
diff --git a/core/auth/ui/e2e/stack.go b/core/auth/ui/e2e/stack.go
new file mode 100644
index 0000000..9bf17fa
--- /dev/null
+++ b/core/auth/ui/e2e/stack.go
@@ -0,0 +1,517 @@
+package e2e
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+type stackPorts struct {
+ KratosPublic int `json:"kratos_public"`
+ KratosAdmin int `json:"kratos_admin"`
+ HydraPublic int `json:"hydra_public"`
+ HydraAdmin int `json:"hydra_admin"`
+ UI int `json:"ui"`
+ API int `json:"api"`
+ SMTP int `json:"smtp"`
+}
+
+type Stack struct {
+ Repo string
+ Workspace string
+ ArtifactDir string
+ Ports stackPorts
+ KratosURL string
+ KratosAdmin string
+ HydraURL string
+ HydraAdmin string
+ UIURL string
+ APIURL string
+ Kratos *supervisedProcess
+ Hydra *supervisedProcess
+ AuthUI *supervisedProcess
+ startedAt time.Time
+ ready bool
+ lastStatus string
+ metadataMu sync.Mutex
+ redactions []string
+ ownershipMu sync.Mutex
+ cleanupStarted bool
+ stopOnce sync.Once
+ stopErr error
+ startProcessOp func(string, string, string, string, ...string) (*supervisedProcess, error)
+ stopProcessOp func(*supervisedProcess, time.Duration) error
+}
+
+func startStack() (*Stack, error) {
+ return startStackOwned(nil)
+}
+
+func startStackOwned(owner *suiteLifecycle) (*Stack, error) {
+ if _, err := artifactsFor(runtime.GOOS, runtime.GOARCH); err != nil {
+ return nil, err
+ }
+ repo, err := repositoryDir()
+ if err != nil {
+ return nil, err
+ }
+ workspace, err := os.MkdirTemp("", "auth-ui-e2e-*")
+ if err != nil {
+ return nil, err
+ }
+ runDir, err := createRunArtifactDir(repo)
+ if err != nil {
+ _ = os.RemoveAll(workspace)
+ return nil, err
+ }
+ s := &Stack{Repo: repo, Workspace: workspace, ArtifactDir: runDir, startedAt: time.Now().UTC()}
+ if owner != nil && !owner.setStack(s) {
+ _ = s.Close(false)
+ return s, fmt.Errorf("E2E watchdog fired before stack ownership registration")
+ }
+ if err := s.prepare(); err != nil {
+ s.recordRun("setup_failed", err)
+ return s, fmt.Errorf("prepare E2E stack: %w", err)
+ }
+ for attempt := 1; attempt <= 3; attempt++ {
+ err = s.startAttempt()
+ if err == nil {
+ s.metadataMu.Lock()
+ s.ready = true
+ s.metadataMu.Unlock()
+ s.recordRun("ready", nil)
+ return s, nil
+ }
+ cleanupErr := s.stopProcessSet()
+ retry, attemptErr := startupRetryDecision(attempt, err, cleanupErr)
+ if !retry {
+ s.recordRun("setup_failed", attemptErr)
+ return s, attemptErr
+ }
+ }
+ return s, err
+}
+
+func (s *Stack) prepare() error {
+ for _, dir := range []string{"bin", "config", "logs"} {
+ if err := os.MkdirAll(filepath.Join(s.Workspace, dir), 0o700); err != nil {
+ return err
+ }
+ }
+ platform, _ := artifactsFor(runtime.GOOS, runtime.GOARCH)
+ offline := os.Getenv("AUTH_UI_E2E_OFFLINE") == "1"
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ defer cancel()
+ for _, artifact := range []releaseArtifact{platform.Kratos, platform.Hydra} {
+ archivePath, err := ensureArchive(ctx, downloadClient(), cacheRoot(s.Repo), artifact, offline)
+ if err != nil {
+ return err
+ }
+ binary, err := extractBinary(archivePath, filepath.Join(s.Workspace, "bin"), artifact.Service)
+ if err != nil {
+ return err
+ }
+ if err := verifyBinaryVersion(binary, artifact.Version); err != nil {
+ return err
+ }
+ }
+ ctx, cancel = context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "go", "build", "-o", filepath.Join(s.Workspace, "bin", "auth-ui"), ".")
+ cmd.Dir = s.Repo
+ output, err := cmd.CombinedOutput()
+ if ctx.Err() != nil {
+ return fmt.Errorf("build auth-ui: %w", ctx.Err())
+ }
+ if err != nil {
+ return fmt.Errorf("build auth-ui: %w: %s", err, boundedText(output, 8192))
+ }
+ return nil
+}
+
+func (s *Stack) startAttempt() error {
+ reservations, ports, err := reservePorts(7)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ for _, reservation := range reservations {
+ _ = reservation.Close()
+ }
+ }()
+ s.metadataMu.Lock()
+ s.Ports = stackPorts{ports[0], ports[1], ports[2], ports[3], ports[4], ports[5], ports[6]}
+ s.KratosURL = loopbackURL(s.Ports.KratosPublic)
+ s.KratosAdmin = loopbackURL(s.Ports.KratosAdmin)
+ s.HydraURL = loopbackURL(s.Ports.HydraPublic)
+ s.HydraAdmin = loopbackURL(s.Ports.HydraAdmin)
+ s.UIURL = loopbackURL(s.Ports.UI)
+ s.APIURL = loopbackURL(s.Ports.API)
+ s.metadataMu.Unlock()
+ cookie, err := randomSecret(16)
+ if err != nil {
+ return err
+ }
+ cipher, err := randomSecret(16)
+ if err != nil {
+ return err
+ }
+ hydra, err := randomSecret(32)
+ if err != nil {
+ return err
+ }
+ s.metadataMu.Lock()
+ s.redactions = []string{cookie, cipher, hydra}
+ s.metadataMu.Unlock()
+ values := fixtureValues{
+ KratosPublicURL: s.KratosURL, KratosAdminURL: s.KratosAdmin,
+ KratosPublicPort: s.Ports.KratosPublic, KratosAdminPort: s.Ports.KratosAdmin,
+ HydraPublicURL: s.HydraURL, HydraAdminURL: s.HydraAdmin,
+ HydraPublicPort: s.Ports.HydraPublic, HydraAdminPort: s.Ports.HydraAdmin,
+ UIURL: s.UIURL, SMTPPort: s.Ports.SMTP,
+ CookieSecret: cookie, CipherSecret: cipher, HydraSecret: hydra,
+ }
+ if err := renderFixtures(s.Repo, filepath.Join(s.Workspace, "config"), values); err != nil {
+ return err
+ }
+ _ = reservations[6].Close() // No courier is started; only a valid unused SMTP address is needed.
+
+ _ = reservations[0].Close()
+ _ = reservations[1].Close()
+ process, err := s.startOwnedProcess(&s.Kratos, "kratos", s.logPath("kratos"), s.Workspace, s.binary("kratos"), "serve", "-c", s.config("kratos.yml"), "--dev", "--sqa-opt-out")
+ if err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ err = waitHTTPReady(ctx, process, s.KratosURL+"/health/ready", s.KratosAdmin+"/health/ready")
+ cancel()
+ if err != nil {
+ return err
+ }
+
+ _ = reservations[2].Close()
+ _ = reservations[3].Close()
+ process, err = s.startOwnedProcess(&s.Hydra, "hydra", s.logPath("hydra"), s.Workspace, s.binary("hydra"), "serve", "-c", s.config("hydra.yml"), "all", "--dev", "--sqa-opt-out")
+ if err != nil {
+ return err
+ }
+ ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
+ err = waitHTTPReady(ctx, process, s.HydraURL+"/health/ready", s.HydraAdmin+"/health/ready")
+ cancel()
+ if err != nil {
+ return err
+ }
+
+ args := []string{"-port", strconv.Itoa(s.Ports.UI), "-api-port", strconv.Itoa(s.Ports.API), "-kratos", s.KratosURL, "-kratos-api", s.KratosAdmin, "-hydra", "127.0.0.1:" + strconv.Itoa(s.Ports.HydraAdmin), "-enable-registration=true", "-email-domain=example.test"}
+ _ = reservations[4].Close()
+ _ = reservations[5].Close()
+ process, err = s.startOwnedProcess(&s.AuthUI, "auth-ui", s.logPath("auth-ui"), s.Repo, s.binary("auth-ui"), args...)
+ if err != nil {
+ return err
+ }
+ ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
+ err = waitTCPReady(ctx, process,
+ "127.0.0.1:"+strconv.Itoa(s.Ports.UI),
+ "127.0.0.1:"+strconv.Itoa(s.Ports.API),
+ )
+ cancel()
+ return err
+}
+
+func reservePorts(count int) ([]net.Listener, []int, error) {
+ listeners := make([]net.Listener, 0, count)
+ ports := make([]int, 0, count)
+ for len(ports) < count {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ for _, reserved := range listeners {
+ _ = reserved.Close()
+ }
+ return nil, nil, err
+ }
+ listeners = append(listeners, listener)
+ ports = append(ports, listener.Addr().(*net.TCPAddr).Port)
+ }
+ return listeners, ports, nil
+}
+
+func loopbackURL(port int) string { return "http://127.0.0.1:" + strconv.Itoa(port) }
+func (s *Stack) binary(name string) string { return filepath.Join(s.Workspace, "bin", name) }
+func (s *Stack) config(name string) string { return filepath.Join(s.Workspace, "config", name) }
+func (s *Stack) logPath(name string) string { return filepath.Join(s.Workspace, "logs", name+".log") }
+
+func isBindConflict(err error) bool {
+ text := strings.ToLower(err.Error())
+ return strings.Contains(text, "address already in use") || strings.Contains(text, "address in use")
+}
+
+func startupRetryDecision(attempt int, startupErr, cleanupErr error) (bool, error) {
+ if cleanupErr != nil {
+ return false, fmt.Errorf("startup attempt %d failed: %v; retry cleanup failed: %w", attempt, startupErr, cleanupErr)
+ }
+ if !isBindConflict(startupErr) || attempt >= 3 {
+ return false, startupErr
+ }
+ return true, nil
+}
+
+func (s *Stack) startOwnedProcess(slot **supervisedProcess, name, logPath, dir, executable string, args ...string) (*supervisedProcess, error) {
+ // Cleanup and process creation share this lock from the terminal-state check
+ // through cmd.Start and slot publication. StopServices can therefore either
+ // prevent the spawn or observe the complete ownership; it cannot snapshot a
+ // nil slot while a process is being created.
+ s.ownershipMu.Lock()
+ defer s.ownershipMu.Unlock()
+ if s.cleanupStarted {
+ return nil, fmt.Errorf("E2E cleanup started before launching %s", name)
+ }
+ start := s.startProcessOp
+ if start == nil {
+ start = startProcess
+ }
+ process, err := start(name, logPath, dir, executable, args...)
+ if err != nil {
+ return process, err
+ }
+ if process == nil {
+ return nil, fmt.Errorf("started %s process ownership is unavailable", name)
+ }
+ process.redactions = append([]string(nil), s.redactions...)
+ *slot = process
+ return process, nil
+}
+
+func (s *Stack) ownedProcessGroups() []int {
+ if s == nil {
+ return nil
+ }
+ s.ownershipMu.Lock()
+ defer s.ownershipMu.Unlock()
+ return s.ownedProcessGroupsLocked()
+}
+
+func (s *Stack) emergencyProcessGroups() []int {
+ if s == nil || !s.ownershipMu.TryLock() {
+ // Descendant discovery remains available when an OS start is holding the
+ // ownership lock, so emergency exit must never block on this mutex.
+ return nil
+ }
+ defer s.ownershipMu.Unlock()
+ return s.ownedProcessGroupsLocked()
+}
+
+func (s *Stack) ownedProcessGroupsLocked() []int {
+ var groups []int
+ for _, process := range []*supervisedProcess{s.AuthUI, s.Hydra, s.Kratos} {
+ if process != nil && process.pgid > 0 {
+ groups = append(groups, process.pgid)
+ }
+ }
+ return groups
+}
+
+func (s *Stack) stopOwnedProcess(process *supervisedProcess, timeout time.Duration) error {
+ if s.stopProcessOp != nil {
+ return s.stopProcessOp(process, timeout)
+ }
+ return process.stopAndWait(timeout)
+}
+
+func (s *Stack) stopProcessSet() error {
+ s.ownershipMu.Lock()
+ processes := []*supervisedProcess{s.AuthUI, s.Hydra, s.Kratos}
+ s.ownershipMu.Unlock()
+ var errs []string
+ for _, process := range processes {
+ if process == nil {
+ continue
+ }
+ stopErr := s.stopOwnedProcess(process, 5*time.Second)
+ if stopErr != nil {
+ errs = append(errs, stopErr.Error())
+ }
+ // Do not discard ownership merely because cleanup returned. Retain the
+ // process until its complete owned group is confirmed absent.
+ if ownedProcessGroupAlive(process.pgid) {
+ if stopErr == nil {
+ errs = append(errs, fmt.Sprintf("%s process group %d survived cleanup", process.name, process.pgid))
+ }
+ } else {
+ s.ownershipMu.Lock()
+ if s.AuthUI == process {
+ s.AuthUI = nil
+ }
+ if s.Hydra == process {
+ s.Hydra = nil
+ }
+ if s.Kratos == process {
+ s.Kratos = nil
+ }
+ s.ownershipMu.Unlock()
+ }
+ }
+ if len(errs) > 0 {
+ return fmt.Errorf("stop service processes: %s", strings.Join(errs, "; "))
+ }
+ return nil
+}
+
+func (s *Stack) StopServices() error {
+ if s == nil {
+ return nil
+ }
+ s.ownershipMu.Lock()
+ s.cleanupStarted = true
+ s.ownershipMu.Unlock()
+ s.stopOnce.Do(func() { s.stopErr = s.stopProcessSet() })
+ return s.stopErr
+}
+
+func (s *Stack) Close(success bool) error {
+ if s == nil {
+ return nil
+ }
+ stopErr := s.StopServices()
+ finalizeErr := s.Finalize(success)
+ if stopErr != nil && finalizeErr != nil {
+ return fmt.Errorf("%v; %w", stopErr, finalizeErr)
+ }
+ if stopErr != nil {
+ return stopErr
+ }
+ return finalizeErr
+}
+
+func (s *Stack) Finalize(success bool) error {
+ s.metadataMu.Lock()
+ ready, lastStatus := s.ready, s.lastStatus
+ redactions := append([]string(nil), s.redactions...)
+ s.metadataMu.Unlock()
+ if ready {
+ if success {
+ s.recordRun("passed", nil)
+ } else if lastStatus == "ready" {
+ s.recordRun("failed", nil)
+ }
+ }
+ var errs []string
+ services := filepath.Join(s.ArtifactDir, "services")
+ if err := os.MkdirAll(services, 0o700); err != nil {
+ errs = append(errs, err.Error())
+ }
+ for _, name := range []string{"auth-ui", "hydra", "kratos"} {
+ if err := copyFileBounded(s.logPath(name), filepath.Join(services, name+".log"), 1<<20, redactions); err != nil && !errors.Is(err, os.ErrNotExist) {
+ errs = append(errs, err.Error())
+ }
+ }
+ keep := !success || os.Getenv("AUTH_UI_E2E_KEEP_TMP") == "1"
+ if keep {
+ fmt.Fprintf(os.Stderr, "E2E workspace retained at %s\nE2E artifacts retained at %s\n", s.Workspace, s.ArtifactDir)
+ } else if err := os.RemoveAll(s.Workspace); err != nil {
+ errs = append(errs, err.Error())
+ }
+ if len(errs) > 0 {
+ return fmt.Errorf("stack cleanup: %s", strings.Join(errs, "; "))
+ }
+ return nil
+}
+
+func createRunArtifactDir(repo string) (string, error) {
+ root := os.Getenv("AUTH_UI_E2E_ARTIFACT_DIR")
+ if root == "" {
+ root = filepath.Join(repo, "e2e", "artifacts")
+ }
+ if err := os.MkdirAll(root, 0o700); err != nil {
+ return "", err
+ }
+ random, err := randomSecret(4)
+ if err != nil {
+ return "", err
+ }
+ name := fmt.Sprintf("%s-%d-%s", time.Now().UTC().Format("20060102T150405.000000000Z"), os.Getpid(), random)
+ path := filepath.Join(root, name)
+ return path, os.Mkdir(path, 0o700)
+}
+
+func (s *Stack) recordRun(status string, setupErr error) {
+ s.metadataMu.Lock()
+ defer s.metadataMu.Unlock()
+ if s.lastStatus == "watchdog_timeout" && status != "watchdog_timeout" {
+ return
+ }
+ s.lastStatus = status
+ metadata := map[string]any{"started_at": s.startedAt, "updated_at": time.Now().UTC(), "status": status, "workspace": s.Workspace, "platform": runtime.GOOS + "/" + runtime.GOARCH, "kratos_version": kratosVersion, "hydra_version": hydraVersion, "playwright_binding_version": playwrightVersion, "playwright_cli_version": playwrightCLIVersion, "chromium_revision": chromiumRevision, "chromium_version": chromiumVersion, "ffmpeg_revision": ffmpegRevision, "ports": s.Ports}
+ if setupErr != nil {
+ metadata["error"] = redactDiagnostic(setupErr.Error())
+ }
+ data, err := json.MarshalIndent(metadata, "", " ")
+ if err == nil {
+ _ = os.WriteFile(filepath.Join(s.ArtifactDir, "run.json"), append(data, '\n'), 0o600)
+ }
+}
+
+func redactDiagnostic(value string) string {
+ // Text logs intentionally omit generated secrets. Strip query strings from URLs
+ // as a final guard against opaque flow/challenge/token values.
+ words := strings.Fields(value)
+ for i, word := range words {
+ if strings.Contains(word, "http://") && strings.Contains(word, "?") {
+ words[i] = strings.SplitN(word, "?", 2)[0]
+ }
+ }
+ return strings.Join(words, " ")
+}
+
+func copyFileBounded(source, destination string, limit int64, redactions []string) error {
+ f, err := os.Open(source)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ info, err := f.Stat()
+ if err != nil {
+ return err
+ }
+ if info.Size() > limit {
+ if _, err := f.Seek(info.Size()-limit, io.SeekStart); err != nil {
+ return err
+ }
+ }
+ data, err := io.ReadAll(io.LimitReader(f, limit))
+ if err != nil {
+ return err
+ }
+ text := string(data)
+ for _, secret := range redactions {
+ if secret != "" {
+ text = strings.ReplaceAll(text, secret, "[REDACTED]")
+ }
+ }
+ return os.WriteFile(destination, []byte(text), 0o600)
+}
+
+func (s *Stack) healthy(endpoint string) error {
+ client := &http.Client{Timeout: 2 * time.Second}
+ defer client.CloseIdleConnections()
+ resp, err := client.Get(endpoint)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("health status %s", resp.Status)
+ }
+ return nil
+}