auth-ui: add e2e tests

Change-Id: Ic8f2f9e032d24eed2d4fd824dcfc26c59d7d915e
diff --git a/core/auth/ui/e2e/artifacts.go b/core/auth/ui/e2e/artifacts.go
new file mode 100644
index 0000000..fda096e
--- /dev/null
+++ b/core/auth/ui/e2e/artifacts.go
@@ -0,0 +1,212 @@
+package e2e
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"time"
+)
+
+const (
+	maxOryArchiveDownloadSize int64 = 512 << 20
+
+	kratosVersion        = "v1.1.0"
+	hydraVersion         = "v2.2.0"
+	playwrightVersion    = "v0.6100.0"
+	playwrightCLIVersion = "1.61.1"
+	chromiumRevision     = "1228"
+	chromiumVersion      = "149.0.7827.55"
+	ffmpegRevision       = "1011"
+)
+
+type releaseArtifact struct {
+	Service string
+	Version string
+	Archive string
+	SHA256  string
+	URL     string
+}
+
+type platformArtifacts struct {
+	Kratos releaseArtifact
+	Hydra  releaseArtifact
+}
+
+// Hashes are from the official release checksum manifests:
+// https://github.com/ory/kratos/releases/download/v1.1.0/checksums.txt
+// https://github.com/ory/hydra/releases/download/v2.2.0/checksums.txt
+var artifactPlatforms = map[string]platformArtifacts{
+	"linux/amd64":  platform("kratos_1.1.0-linux_sqlite_64bit.tar.gz", "6fb3077252dde7578c3100d2cd4eb52364ca6b3c1b0b76987e6d586e29008cbd", "hydra_2.2.0-linux_sqlite_64bit.tar.gz", "0fe0539fa452496ac5d98b558f93eb2dbb4cf43733da0b09f8f2bdb4445fc31e"),
+	"linux/arm64":  platform("kratos_1.1.0-linux_sqlite_arm64.tar.gz", "fde8a1a1aebd153baff88b1232e0c2a34fdaaafe90b5364f4ea580151e74898e", "hydra_2.2.0-linux_sqlite_arm64.tar.gz", "c499ffdaae0f2ab85eff0567214734515b741a393bef89115c16018f4dc0560d"),
+	"darwin/amd64": platform("kratos_1.1.0-macOS_sqlite_64bit.tar.gz", "ebdc94f27cb6e6a3087ed756accfb7837465ac8e30af9433b4414101814f7769", "hydra_2.2.0-macOS_sqlite_64bit.tar.gz", "3d40ca8e99e2a6d840130928d5e0245212dba0eea9c26a0d7186ebb4382e673d"),
+	"darwin/arm64": platform("kratos_1.1.0-macOS_sqlite_arm64.tar.gz", "6681d7b15dd04686d10764750ce3ad69672b3962553223399a3a315ba5370517", "hydra_2.2.0-macOS_sqlite_arm64.tar.gz", "89732ad1494c57ea39348f62dc5ef5c48de129cd205b17cb12bb67ad27094bb7"),
+}
+
+func platform(kratosArchive, kratosHash, hydraArchive, hydraHash string) platformArtifacts {
+	return platformArtifacts{
+		Kratos: releaseArtifact{"kratos", kratosVersion, kratosArchive, kratosHash, "https://github.com/ory/kratos/releases/download/" + kratosVersion + "/" + kratosArchive},
+		Hydra:  releaseArtifact{"hydra", hydraVersion, hydraArchive, hydraHash, "https://github.com/ory/hydra/releases/download/" + hydraVersion + "/" + hydraArchive},
+	}
+}
+
+func artifactsFor(goos, goarch string) (platformArtifacts, error) {
+	p, ok := artifactPlatforms[goos+"/"+goarch]
+	if !ok {
+		return platformArtifacts{}, fmt.Errorf("unsupported E2E platform %s/%s (supported: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64)", goos, goarch)
+	}
+	return p, nil
+}
+
+func repositoryDir() (string, error) {
+	_, source, _, ok := runtime.Caller(0)
+	if !ok {
+		return "", errors.New("locate e2e source file")
+	}
+	dir := filepath.Dir(filepath.Dir(source))
+	if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil {
+		return "", fmt.Errorf("locate auth-ui repository: %w", err)
+	}
+	return dir, nil
+}
+
+func cacheRoot(repo string) string {
+	if root := os.Getenv("AUTH_UI_E2E_CACHE_DIR"); root != "" {
+		return root
+	}
+	return filepath.Join(repo, "e2e", "cache")
+}
+
+func archiveCachePath(root string, artifact releaseArtifact) string {
+	return filepath.Join(root, "ory", artifact.Service, artifact.Version, artifact.Archive)
+}
+
+func ensureArchive(ctx context.Context, client *http.Client, root string, artifact releaseArtifact, offline bool) (string, error) {
+	return ensureArchiveWithLimit(ctx, client, root, artifact, offline, maxOryArchiveDownloadSize)
+}
+
+func ensureArchiveWithLimit(ctx context.Context, client *http.Client, root string, artifact releaseArtifact, offline bool, maxSize int64) (string, error) {
+	path := archiveCachePath(root, artifact)
+	valid, actual, err := validSHA256(path, artifact.SHA256)
+	if err != nil && !errors.Is(err, os.ErrNotExist) {
+		return "", err
+	}
+	if valid {
+		return path, nil
+	}
+	if offline {
+		return "", fmt.Errorf("offline E2E cache is missing or invalid: %s (expected sha256 %s, got %s); populate it with an online E2E run", path, artifact.SHA256, printableHash(actual))
+	}
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return "", err
+	}
+	unlock, err := acquireCacheLock(ctx, path+".lock")
+	if err != nil {
+		return "", fmt.Errorf("lock archive cache %s: %w", path, err)
+	}
+	defer unlock()
+
+	// Another harness process may have published a valid archive while this
+	// caller waited for the lock. Revalidate before removing the invalid path.
+	valid, actual, err = validSHA256(path, artifact.SHA256)
+	if err != nil && !errors.Is(err, os.ErrNotExist) {
+		return "", err
+	}
+	if valid {
+		return path, nil
+	}
+	if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
+		return "", fmt.Errorf("remove invalid cache file: %w", err)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, artifact.URL, nil)
+	if err != nil {
+		return "", err
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("download %s: %w", artifact.Archive, err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		_, _ = io.CopyN(io.Discard, resp.Body, 4096)
+		return "", fmt.Errorf("download %s: unexpected HTTP status %s", artifact.Archive, resp.Status)
+	}
+	if resp.ContentLength > maxSize {
+		return "", fmt.Errorf("download %s exceeds maximum archive size %d bytes (content length %d)", artifact.Archive, maxSize, resp.ContentLength)
+	}
+
+	tmp, err := os.CreateTemp(filepath.Dir(path), "."+artifact.Archive+"-*")
+	if err != nil {
+		return "", err
+	}
+	tmpName := tmp.Name()
+	published := false
+	defer func() {
+		_ = tmp.Close()
+		if !published {
+			_ = os.Remove(tmpName)
+		}
+	}()
+	h := sha256.New()
+	written, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(resp.Body, maxSize+1))
+	if err != nil {
+		return "", fmt.Errorf("download %s body: %w", artifact.Archive, err)
+	}
+	if written > maxSize {
+		return "", fmt.Errorf("download %s exceeds maximum archive size %d bytes", artifact.Archive, maxSize)
+	}
+	actual = hex.EncodeToString(h.Sum(nil))
+	if !strings.EqualFold(actual, artifact.SHA256) {
+		return "", fmt.Errorf("download %s checksum mismatch: expected %s, got %s", artifact.Archive, artifact.SHA256, actual)
+	}
+	if err := tmp.Sync(); err != nil {
+		return "", err
+	}
+	if err := tmp.Close(); err != nil {
+		return "", err
+	}
+	if err := os.Rename(tmpName, path); err != nil {
+		// A concurrent writer may have published the same valid archive.
+		if valid, _, checkErr := validSHA256(path, artifact.SHA256); checkErr == nil && valid {
+			return path, nil
+		}
+		return "", fmt.Errorf("publish %s: %w", path, err)
+	}
+	published = true
+	if valid, actual, err := validSHA256(path, artifact.SHA256); err != nil || !valid {
+		_ = os.Remove(path)
+		return "", fmt.Errorf("validate published archive %s: expected %s, got %s: %v", path, artifact.SHA256, actual, err)
+	}
+	return path, nil
+}
+
+func validSHA256(path, expected string) (bool, string, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return false, "", err
+	}
+	defer f.Close()
+	h := sha256.New()
+	if _, err := io.Copy(h, f); err != nil {
+		return false, "", err
+	}
+	actual := hex.EncodeToString(h.Sum(nil))
+	return strings.EqualFold(actual, expected), actual, nil
+}
+
+func printableHash(hash string) string {
+	if hash == "" {
+		return "missing"
+	}
+	return hash
+}
+
+func downloadClient() *http.Client { return &http.Client{Timeout: 2 * time.Minute} }