Ctrl K
ComunidadGuías…

deploy/docker-compose_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package deploy_test

import (
	"context"
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"

	"telesrv/internal/config"
	"telesrv/internal/store/redisstore"
)

func TestLegacyComposeConfigMatrix(t *testing.T) {
	requireCompose(t, false)
	clearDeploymentEnvironment(t)
	root := repositoryRoot(t)
	example, err := os.ReadFile(filepath.Join(root, ".env.example"))
	if err != nil {
		t.Fatal(err)
	}
	for _, tc := range []struct {
		name, env, postgres, redis string
		overrides                  map[string]string
	}{
		{name: "no env", postgres: "telesrv"},
		{name: "example", env: string(example), postgres: "telesrv"},
		{name: "existing empty passwords", env: "TELESRV_POSTGRES_PASSWORD=\nTELESRV_REDIS_PASSWORD=\n", postgres: "telesrv"},
		{name: "custom passwords", env: "TELESRV_POSTGRES_PASSWORD=pg-custom\nTELESRV_REDIS_PASSWORD=redis-custom\n", postgres: "pg-custom", redis: "redis-custom"},
		{name: "process overrides", env: "TELESRV_POSTGRES_PASSWORD=from-file\nTELESRV_REDIS_PASSWORD=from-file\n", postgres: "from-process", redis: "from-process", overrides: map[string]string{"TELESRV_POSTGRES_PASSWORD": "from-process", "TELESRV_REDIS_PASSWORD": "from-process"}},
		{name: "empty process overrides", env: "TELESRV_POSTGRES_PASSWORD=from-file\nTELESRV_REDIS_PASSWORD=from-file\n", postgres: "telesrv", overrides: map[string]string{"TELESRV_POSTGRES_PASSWORD": "", "TELESRV_REDIS_PASSWORD": ""}},
		{name: "reserved characters", env: "TELESRV_POSTGRES_PASSWORD='p@ss:word/with?reserved#chars$literal'\nTELESRV_REDIS_PASSWORD='redis @:#/ $literal'\n", postgres: "p@ss:word/with?reserved#chars$literal", redis: "redis @:#/ $literal"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			for key, value := range tc.overrides {
				t.Setenv(key, value)
			}
			envFile := filepath.Join(t.TempDir(), ".env")
			writeComposeFile(t, envFile, tc.env)
			output := dockerCommand(t, nil, "compose", "--env-file", envFile, "-f", filepath.Join(root, "deploy", "docker-compose.yml"), "config", "--format", "json")
			var model struct {
				Services map[string]struct{ Environment map[string]string }
			}
			if err := json.Unmarshal(output, &model); err != nil {
				t.Fatal(err)
			}
			cfg := loadComposeConfig(t, envFile)
			pg, err := pgx.ParseConfig(cfg.PostgresDSN)
			if err != nil {
				t.Fatal(err)
			}
			// Compose escapes literal dollars for round-tripping its rendered
			// configuration. Live authentication below verifies the real value.
			if got := strings.ReplaceAll(model.Services["postgres"].Environment["POSTGRES_PASSWORD"], "$$", "$"); got != tc.postgres || got != pg.Password {
				t.Fatal("Postgres container, server DSN and expected password differ")
			}
			if pg.User != "telesrv" || pg.Database != "telesrv_main" || pg.Host != "127.0.0.1" || pg.Port != 5432 {
				t.Fatal("derived DSN no longer targets the main development database")
			}
			if got := strings.ReplaceAll(model.Services["redis"].Environment["REDIS_PASSWORD"], "$$", "$"); got != tc.redis || got != cfg.RedisPassword {
				t.Fatal("Redis container, server and expected password differ")
			}
		})
	}
}

func TestLegacyComposeCredentialsIntegration(t *testing.T) {
	if os.Getenv("TELESRV_RUN_COMPOSE_INTEGRATION") != "1" {
		t.Skip("set TELESRV_RUN_COMPOSE_INTEGRATION=1 for the isolated volume upgrade test")
	}
	requireCompose(t, true)
	clearDeploymentEnvironment(t)
	root := repositoryRoot(t)
	project := "gramsrv-credentials-" + strings.ToLower(rand.Text()[:12])
	dir := t.TempDir()
	envFile := filepath.Join(dir, ".env")
	override := filepath.Join(dir, "compose.test.yaml")
	writeComposeFile(t, envFile, "")
	// Only names, published ports and authentication policy are overridden.
	// Exercise the real commands, healthchecks, init scripts and named volumes
	// without touching a developer's stack.
	writeComposeFile(t, override, fmt.Sprintf(`services:
  postgres:
    container_name: %s-postgres
    environment:
      POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256 --auth-local=trust
    ports: !override ["127.0.0.1::5432"]
  redis:
    container_name: %s-redis
    ports: !override ["127.0.0.1::6379"]
`, project, project))
	compose := []string{"compose", "-p", project, "--env-file", envFile, "-f", filepath.Join(root, "deploy", "docker-compose.yml"), "-f", override}
	run := func(stdin *strings.Reader, args ...string) []byte {
		return dockerCommand(t, stdin, append(append([]string{}, compose...), args...)...)
	}
	t.Cleanup(func() { run(nil, "down", "--volumes", "--remove-orphans") })
	start := func() (string, string) {
		run(nil, "up", "-d", "--force-recreate", "--wait", "--wait-timeout", "60")
		return strings.TrimSpace(string(run(nil, "port", "postgres", "5432"))), strings.TrimSpace(string(run(nil, "port", "redis", "6379")))
	}

	pgAddr, redisAddr := start()
	cfg := loadComposeConfig(t, envFile)
	pg := connectComposePostgres(t, cfg.PostgresDSN, pgAddr)
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	_, err := pg.Exec(ctx, "CREATE TABLE credential_rotation_probe (value text PRIMARY KEY); INSERT INTO credential_rotation_probe VALUES ('retained')")
	cancel()
	pg.Close(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	checkComposeRedis(t, cfg, redisAddr)

	// Old .env files explicitly set an empty Redis password. A restart must
	// preserve connectivity and the existing PostgreSQL volume.
	writeComposeFile(t, envFile, "TELESRV_REDIS_PASSWORD=\n")
	pgAddr, redisAddr = start()
	cfg = loadComposeConfig(t, envFile)
	checkComposeRedis(t, cfg, redisAddr)
	checkComposeMarker(t, cfg.PostgresDSN, pgAddr)
	oldDSN := cfg.PostgresDSN

	const newPGPassword = "new@pg:/?password#$literal"
	writeComposeFile(t, envFile, "TELESRV_POSTGRES_PASSWORD='"+newPGPassword+"'\nTELESRV_REDIS_PASSWORD='new redis@:# $literal'\n")
	pgAddr, redisAddr = start()
	cfg = loadComposeConfig(t, envFile)
	checkComposeRedis(t, cfg, redisAddr)
	// Changing container environment alone does not rotate the persisted role.
	checkComposeMarker(t, oldDSN, pgAddr)
	requirePostgresAuthFailure(t, cfg.PostgresDSN, pgAddr)

	// Exercise the documented psql \password command. Without a TTY, psql
	// reads its two password prompts from stdin. No password enters SQL or argv.
	run(strings.NewReader(newPGPassword+"\n"+newPGPassword+"\n"), "exec", "-T", "postgres", "psql", "-U", "telesrv", "-d", "postgres", "-v", "ON_ERROR_STOP=1", "-c", "\\password telesrv")
	checkComposeMarker(t, cfg.PostgresDSN, pgAddr)
	requirePostgresAuthFailure(t, oldDSN, pgAddr)

	pgAddr, redisAddr = start()
	checkComposeMarker(t, cfg.PostgresDSN, pgAddr)
	requirePostgresAuthFailure(t, oldDSN, pgAddr)
	checkComposeRedis(t, cfg, redisAddr)
}

func checkComposeRedis(t *testing.T, cfg config.Config, addr string) {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	client, err := redisstore.Open(ctx, addr, cfg.RedisPassword, cfg.RedisDB)
	if err != nil {
		t.Fatalf("server Redis credentials: %v", err)
	}
	client.Close()
	if cfg.RedisPassword != "" {
		client, err := redisstore.Open(ctx, addr, "incorrect-password", cfg.RedisDB)
		if err == nil {
			client.Close()
			t.Fatal("Redis accepted an incorrect password")
		}
		if !strings.Contains(err.Error(), "WRONGPASS") {
			t.Fatalf("expected Redis authentication rejection, got: %v", err)
		}
	}
}

func composePostgresConfig(t *testing.T, dsn, addr string) *pgx.ConnConfig {
	t.Helper()
	cfg, err := pgx.ParseConfig(dsn)
	if err != nil {
		t.Fatal(err)
	}
	host, port, err := net.SplitHostPort(addr)
	if err != nil {
		t.Fatal(err)
	}
	parsed, err := strconv.ParseUint(port, 10, 16)
	if err != nil {
		t.Fatal(err)
	}
	cfg.Host, cfg.Port = host, uint16(parsed)
	return cfg
}

func connectComposePostgres(t *testing.T, dsn, addr string) *pgx.Conn {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	conn, err := pgx.ConnectConfig(ctx, composePostgresConfig(t, dsn, addr))
	if err != nil {
		t.Fatalf("server Postgres credentials: %v", err)
	}
	return conn
}

func checkComposeMarker(t *testing.T, dsn, addr string) {
	t.Helper()
	conn := connectComposePostgres(t, dsn, addr)
	defer conn.Close(context.Background())
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	var value string
	if err := conn.QueryRow(ctx, "SELECT value FROM credential_rotation_probe").Scan(&value); err != nil || value != "retained" {
		t.Fatalf("existing volume marker: value=%q err=%v", value, err)
	}
}

func requirePostgresAuthFailure(t *testing.T, dsn, addr string) {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	conn, err := pgx.ConnectConfig(ctx, composePostgresConfig(t, dsn, addr))
	if err == nil {
		conn.Close(context.Background())
		t.Fatal("Postgres accepted an incorrect password")
	}
	var pgErr *pgconn.PgError
	if !errors.As(err, &pgErr) || pgErr.Code != "28P01" {
		t.Fatalf("expected Postgres authentication rejection, got: %v", err)
	}
}

func loadComposeConfig(t *testing.T, envFile string) config.Config {
	t.Helper()
	t.Setenv("TELESRV_CONFIG", envFile)
	cfg, err := config.Load()
	if err != nil {
		t.Fatal(err)
	}
	return cfg
}

func clearDeploymentEnvironment(t *testing.T) {
	t.Helper()
	for _, entry := range os.Environ() {
		key, _, _ := strings.Cut(entry, "=")
		if strings.HasPrefix(key, "TELESRV_") || strings.HasPrefix(key, "COMPOSE_") || strings.HasPrefix(key, "PG") {
			// Setenv registers restoration; unset then lets the supplied file
			// win instead of an inherited shell variable (even an empty one).
			t.Setenv(key, "")
			if err := os.Unsetenv(key); err != nil {
				t.Fatal(err)
			}
		}
	}
}

func requireCompose(t *testing.T, integration bool) {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := exec.CommandContext(ctx, "docker", "compose", "version").Run(); err != nil {
		if integration {
			t.Fatalf("Docker Compose is required for integration tests: %v", err)
		}
		t.Skip("Docker Compose is not installed")
	}
}

func dockerCommand(t *testing.T, stdin *strings.Reader, args ...string) []byte {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
	defer cancel()
	cmd := exec.CommandContext(ctx, "docker", args...)
	cmd.Dir = repositoryRoot(t)
	if stdin != nil {
		cmd.Stdin = stdin
	}
	output, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("docker %s: %v\n%s", strings.Join(args, " "), err, output)
	}
	return output
}

func writeComposeFile(t *testing.T, path, body string) {
	t.Helper()
	if err := os.WriteFile(path, []byte(body), 0600); err != nil {
		t.Fatal(err)
	}
}

func repositoryRoot(t *testing.T) string {
	t.Helper()
	_, filename, _, ok := runtime.Caller(0)
	if !ok {
		t.Fatal("runtime.Caller failed")
	}
	root, err := filepath.Abs(filepath.Join(filepath.Dir(filename), ".."))
	if err != nil {
		t.Fatal(err)
	}
	return root
}