auth-ui: add e2e tests

Change-Id: Ic8f2f9e032d24eed2d4fd824dcfc26c59d7d915e
diff --git a/core/auth/ui/e2e/process.go b/core/auth/ui/e2e/process.go
new file mode 100644
index 0000000..2de96de
--- /dev/null
+++ b/core/auth/ui/e2e/process.go
@@ -0,0 +1,222 @@
+package e2e
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"os/exec"
+	"strings"
+	"sync"
+	"time"
+)
+
+type processSignal int
+
+const (
+	processTerminate processSignal = iota
+	processKill
+)
+
+type supervisedProcess struct {
+	name       string
+	cmd        *exec.Cmd
+	logPath    string
+	logFile    *os.File
+	pgid       int
+	completed  chan struct{}
+	resultMu   sync.RWMutex
+	result     error
+	stop       sync.Once
+	stopErr    error
+	redactions []string
+}
+
+func startProcess(name, logPath, dir, executable string, args ...string) (*supervisedProcess, error) {
+	logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
+	if err != nil {
+		return nil, err
+	}
+	cmd := exec.Command(executable, args...)
+	cmd.Dir = dir
+	cmd.Stdout = logFile
+	cmd.Stderr = logFile
+	if err := configureOwnedProcess(cmd); err != nil {
+		_ = logFile.Close()
+		return nil, fmt.Errorf("configure %s process ownership: %w", name, err)
+	}
+	if err := cmd.Start(); err != nil {
+		_ = logFile.Close()
+		return nil, fmt.Errorf("start %s: %w", name, err)
+	}
+	p := &supervisedProcess{name: name, cmd: cmd, logPath: logPath, logFile: logFile, pgid: cmd.Process.Pid, completed: make(chan struct{})}
+	go func() {
+		err := cmd.Wait()
+		_ = logFile.Close()
+		p.resultMu.Lock()
+		p.result = err
+		p.resultMu.Unlock()
+		close(p.completed)
+	}()
+	return p, nil
+}
+
+func (p *supervisedProcess) alive() bool {
+	if p == nil {
+		return false
+	}
+	select {
+	case <-p.completed:
+		return false
+	default:
+		return true
+	}
+}
+
+func (p *supervisedProcess) completion() error {
+	p.resultMu.RLock()
+	defer p.resultMu.RUnlock()
+	return p.result
+}
+
+func (p *supervisedProcess) stopAndWait(timeout time.Duration) error {
+	if p == nil {
+		return nil
+	}
+	p.stop.Do(func() {
+		// Signal the group even when the leader has already exited: descendants may
+		// still own the process group and are part of this harness's ownership.
+		if err := signalOwnedProcessGroup(p.pgid, processTerminate); err != nil && !errors.Is(err, os.ErrProcessDone) {
+			p.stopErr = fmt.Errorf("terminate %s process group: %w", p.name, err)
+		}
+		deadline := time.Now().Add(timeout)
+		for ownedProcessGroupAlive(p.pgid) && time.Now().Before(deadline) {
+			time.Sleep(10 * time.Millisecond)
+		}
+		if ownedProcessGroupAlive(p.pgid) {
+			if err := signalOwnedProcessGroup(p.pgid, processKill); err != nil && !errors.Is(err, os.ErrProcessDone) && p.stopErr == nil {
+				p.stopErr = fmt.Errorf("kill %s process group: %w", p.name, err)
+			}
+		}
+
+		postKill := time.NewTimer(2 * time.Second)
+		defer postKill.Stop()
+		select {
+		case <-p.completed:
+		case <-postKill.C:
+			if p.stopErr == nil {
+				p.stopErr = fmt.Errorf("wait for %s leader after process-group kill: timeout", p.name)
+			}
+		}
+		groupDeadline := time.Now().Add(2 * time.Second)
+		for ownedProcessGroupAlive(p.pgid) && time.Now().Before(groupDeadline) {
+			time.Sleep(10 * time.Millisecond)
+		}
+		if ownedProcessGroupAlive(p.pgid) && p.stopErr == nil {
+			p.stopErr = fmt.Errorf("%s process group %d survived cleanup", p.name, p.pgid)
+		}
+		// A non-zero leader exit belongs to startup/readiness diagnostics, not
+		// cleanup. Once the owned group is gone, explicit cleanup succeeded.
+	})
+	return p.stopErr
+}
+
+func (p *supervisedProcess) exited() (bool, error) {
+	select {
+	case <-p.completed:
+		return true, p.completion()
+	default:
+		return false, nil
+	}
+}
+
+func (p *supervisedProcess) logTail(limit int64) string {
+	data, err := os.ReadFile(p.logPath)
+	if err != nil {
+		return fmt.Sprintf("<read log: %v>", err)
+	}
+	if int64(len(data)) > limit {
+		data = data[len(data)-int(limit):]
+	}
+	text := boundedText(data, int(limit))
+	for _, secret := range p.redactions {
+		if secret != "" {
+			text = strings.ReplaceAll(text, secret, "[REDACTED]")
+		}
+	}
+	return text
+}
+
+func waitHTTPReady(ctx context.Context, p *supervisedProcess, endpoints ...string) error {
+	client := &http.Client{Timeout: 750 * time.Millisecond}
+	defer client.CloseIdleConnections()
+	for {
+		allReady := true
+		for _, endpoint := range endpoints {
+			req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+			if err != nil {
+				return err
+			}
+			resp, err := client.Do(req)
+			if err != nil {
+				allReady = false
+				break
+			}
+			_, _ = io.CopyN(io.Discard, resp.Body, 4096)
+			_ = resp.Body.Close()
+			if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+				allReady = false
+				break
+			}
+		}
+		if allReady {
+			return nil
+		}
+		if exited, err := p.exited(); exited {
+			return fmt.Errorf("%s exited during readiness: %v\nlog tail:\n%s", p.name, err, p.logTail(8192))
+		}
+		select {
+		case <-ctx.Done():
+			return fmt.Errorf("%s readiness: %w\nlog tail:\n%s", p.name, ctx.Err(), p.logTail(8192))
+		case <-time.After(100 * time.Millisecond):
+		}
+	}
+}
+
+func waitTCPReady(ctx context.Context, p *supervisedProcess, addresses ...string) error {
+	pending := append([]string(nil), addresses...)
+	for len(pending) > 0 {
+		next := pending[:0]
+		for _, address := range pending {
+			conn, err := (&net.Dialer{Timeout: 500 * time.Millisecond}).DialContext(ctx, "tcp", address)
+			if err == nil {
+				_ = conn.Close()
+				continue
+			}
+			next = append(next, address)
+		}
+		pending = next
+		if len(pending) == 0 {
+			return nil
+		}
+		if exited, exitErr := p.exited(); exited {
+			return fmt.Errorf("%s exited during readiness: %v\nlog tail:\n%s", p.name, exitErr, p.logTail(8192))
+		}
+		select {
+		case <-ctx.Done():
+			return fmt.Errorf("%s readiness for %s: %w\nlog tail:\n%s", p.name, strings.Join(pending, ", "), ctx.Err(), p.logTail(8192))
+		case <-time.After(100 * time.Millisecond):
+		}
+	}
+	return nil
+}
+
+func boundedText(data []byte, limit int) string {
+	if len(data) > limit {
+		data = data[len(data)-limit:]
+	}
+	return strings.ToValidUTF8(string(data), "�")
+}