package testenv import ( "context" "crypto/ed25519" "crypto/rand" "crypto/x509" "encoding/pem" "fmt" "path/filepath" "testing" "time" "github.com/testcontainers/testcontainers-go" tcnetwork "github.com/testcontainers/testcontainers-go/network" "github.com/testcontainers/testcontainers-go/wait" ) // GatewayContainer wraps a running galaxy/gateway:integration // container. type GatewayContainer struct { Container testcontainers.Container HTTPHost string HTTPPort int HTTPURL string GRPCHost string GRPCPort int GRPCAddr string // ResponseSignerPublic is the Ed25519 public key the gateway uses // to sign responses and push events. Tests verify signatures // against this value. ResponseSignerPublic ed25519.PublicKey } // GatewayOptions tunes a gateway container before it boots. type GatewayOptions struct { NetworkAlias string NetworkName string BackendHTTPURL string BackendGRPCURL string RedisAddr string GatewayClientID string Extra map[string]string } // StartGateway boots galaxy/gateway:integration with the supplied // options. func StartGateway(t *testing.T, opts GatewayOptions) *GatewayContainer { t.Helper() EnsureGatewayImage(t) if opts.NetworkAlias == "" { opts.NetworkAlias = "gateway" } if opts.GatewayClientID == "" { opts.GatewayClientID = "integration-gateway" } pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("generate ed25519 key: %v", err) } keyDER, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { t.Fatalf("marshal ed25519 key: %v", err) } keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) keyPath := filepath.Join(t.TempDir(), "gateway-signer.pem") if err := writeFile(keyPath, keyPEM); err != nil { t.Fatalf("write signer key: %v", err) } containerKey := "/etc/galaxy/gateway-signer.pem" env := map[string]string{ "GATEWAY_PUBLIC_HTTP_ADDR": ":8080", "GATEWAY_AUTHENTICATED_GRPC_ADDR": ":9090", "GATEWAY_LOG_LEVEL": "debug", "GATEWAY_REDIS_MASTER_ADDR": opts.RedisAddr, "GATEWAY_REDIS_PASSWORD": RedisIntegrationPassword, "GATEWAY_BACKEND_HTTP_URL": opts.BackendHTTPURL, "GATEWAY_BACKEND_GRPC_PUSH_URL": opts.BackendGRPCURL, "GATEWAY_BACKEND_GATEWAY_CLIENT_ID": opts.GatewayClientID, "GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH": containerKey, // Loosen anti-abuse so happy-path scenarios aren't rate-limited. // Negative-path edge tests tighten these per-test. "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_REQUESTS": "10000", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_BURST": "1000", // Identity-bucket limits sit on top of the class limits and are // keyed by the request identity (email for send-email-code, // challenge_id for confirm-email-code). The defaults are // purposely tight in production (3 sends per email per window); // happy-path scenarios that re-issue codes for the same email // would otherwise trip the limiter mid-test. "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "10000", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "1000", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "10000", "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "1000", "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_REQUESTS": "10000", "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_BURST": "1000", "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_REQUESTS": "10000", "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_BURST": "1000", "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_USER_RATE_LIMIT_REQUESTS": "10000", "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_USER_RATE_LIMIT_BURST": "1000", "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_REQUESTS": "10000", "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_BURST": "1000", } for k, v := range opts.Extra { env[k] = v } req := testcontainers.ContainerRequest{ Image: GatewayImage, ExposedPorts: []string{"8080/tcp", "9090/tcp"}, Env: env, WaitingFor: wait.ForHTTP("/healthz"). WithPort("8080/tcp"). WithStartupTimeout(60 * time.Second), Files: []testcontainers.ContainerFile{ { HostFilePath: keyPath, ContainerFilePath: containerKey, // 0o444 so the distroless `nonroot` user (uid 65532) // inside the gateway image can read the integration // signer key. The key is ephemeral and never leaves // the test process, so widening the mode is safe. FileMode: 0o444, }, }, } gcr := &testcontainers.GenericContainerRequest{ContainerRequest: req} if opts.NetworkName != "" { _ = tcnetwork.WithNetwork([]string{opts.NetworkAlias}, &testcontainers.DockerNetwork{Name: opts.NetworkName}).Customize(gcr) } gcr.Started = true ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() container, err := testcontainers.GenericContainer(ctx, *gcr) if err != nil { t.Fatalf("start gateway container: %v", err) } t.Cleanup(func() { if err := testcontainers.TerminateContainer(container); err != nil { t.Logf("terminate gateway: %v", err) } }) host, err := container.Host(ctx) if err != nil { t.Fatalf("gateway host: %v", err) } port, err := container.MappedPort(ctx, "8080/tcp") if err != nil { t.Fatalf("gateway port: %v", err) } grpcPort, err := container.MappedPort(ctx, "9090/tcp") if err != nil { t.Fatalf("gateway grpc port: %v", err) } return &GatewayContainer{ Container: container, HTTPHost: host, HTTPPort: int(port.Num()), HTTPURL: fmt.Sprintf("http://%s:%d", host, port.Num()), GRPCHost: host, GRPCPort: int(grpcPort.Num()), GRPCAddr: fmt.Sprintf("%s:%d", host, grpcPort.Num()), ResponseSignerPublic: pub, } } func writeFile(path string, content []byte) error { return writeFileFn(path, content) }