Files
RenewCMS/test/postgres_database_driver_test.go
T

338 lines
11 KiB
Go

package test
import (
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/persistence/database"
"fmt"
"net/url"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
const postgresTestPassword = "p@ss w'rd/\"x:42%"
func setPostgresEnv(t *testing.T) {
t.Helper()
setBaseSettingsEnv(t)
t.Setenv("DB_DRIVER", "postgres")
t.Setenv("DB_HOST", "db.local")
t.Setenv("DB_USER", "app")
t.Setenv("DB_PASSWORD", postgresTestPassword)
t.Setenv("DB_NAME", "cms")
for _, optional := range []string{"DB_PORT", "DB_TLS", "DB_SSLMODE", "DB_MAX_OPEN_CONNS", "DB_MAX_IDLE_CONNS", "DB_CONN_MAX_LIFETIME_SECONDS", "DATA_DIR"} {
t.Setenv(optional, "")
}
}
func TestPostgresDriverSelection(t *testing.T) {
t.Run("Driver selection", func(t *testing.T) {
cases := []struct {
value string
driver string
}{
{"postgres", "postgres"},
{"postgresql", "postgres"},
{"mysql", "mysql"},
{"oracle", "oracle"},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("DB_DRIVER %q", tc.value), func(t *testing.T) {
setPostgresEnv(t)
t.Setenv("DB_DRIVER", tc.value)
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.Driver != tc.driver {
t.Errorf("expected driver %q, got %+v err %v", tc.driver, settings.Database, err)
}
})
}
})
}
func TestPostgresEnvironmentValidation(t *testing.T) {
complete := map[string]string{"DB_HOST": "h", "DB_USER": "u", "DB_PASSWORD": "p", "DB_NAME": "n"}
t.Run("Missing PostgreSQL variable", func(t *testing.T) {
for _, driver := range []string{"postgres", "postgresql"} {
for _, missing := range []string{"DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME"} {
t.Run(driver+" "+missing, func(t *testing.T) {
values := map[string]string{}
for name, value := range complete {
if name != missing {
values[name] = value
}
}
err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars(driver), lookupFrom(values))
expected := "Environment variable " + missing + " is not set"
if err == nil || err.Error() != expected {
t.Errorf("expected %q, got %v", expected, err)
}
})
}
}
})
t.Run("all PostgreSQL variables set passes validation", func(t *testing.T) {
if err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars("postgres"), lookupFrom(complete)); err != nil {
t.Errorf("expected success, got %v", err)
}
})
t.Run("SQLite and MySQL do not require PostgreSQL-only variables", func(t *testing.T) {
for _, driver := range []string{"sqlite", "mysql"} {
setMySQLEnv(t)
t.Setenv("DB_DRIVER", driver)
t.Setenv("DB_SSLMODE", "maybe")
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.SSLMode != "" {
t.Errorf("driver %q: DB_SSLMODE must be ignored, got %+v err %v", driver, settings.Database, err)
}
}
})
}
func TestPostgresSettingsFromEnv(t *testing.T) {
t.Run("defaults apply when the optional variables are empty", func(t *testing.T) {
setPostgresEnv(t)
settings, err := config.NewSettingsFromEnv()
if err != nil {
t.Fatalf("settings: %v", err)
}
database := settings.Database
if database.Port != "5432" || database.SSLMode != "disable" || database.MaxOpenConns != 10 || database.MaxIdleConns != 5 || database.ConnMaxLifetime != 300*time.Second {
t.Errorf("unexpected defaults %+v", database)
}
if database.Host != "db.local" || database.User != "app" || database.Password != postgresTestPassword || database.Name != "cms" {
t.Errorf("unexpected connection fields %+v", database)
}
})
t.Run("MySQL keeps its default port", func(t *testing.T) {
setMySQLEnv(t)
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.Port != "3306" {
t.Errorf("unexpected settings %+v err %v", settings.Database, err)
}
})
t.Run("Invalid SSL mode is rejected", func(t *testing.T) {
setPostgresEnv(t)
t.Setenv("DB_SSLMODE", "maybe")
_, err := config.NewSettingsFromEnv()
if err == nil {
t.Fatal("expected an error")
}
for _, expected := range []string{"DB_SSLMODE", "disable", "require", "verify-ca", "verify-full"} {
if !strings.Contains(err.Error(), expected) {
t.Errorf("error %q must mention %q", err, expected)
}
}
})
t.Run("every accepted SSL mode is read", func(t *testing.T) {
for _, mode := range []string{"disable", "require", "verify-ca", "verify-full"} {
setPostgresEnv(t)
t.Setenv("DB_SSLMODE", mode)
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.SSLMode != mode {
t.Errorf("mode %q: settings %+v, err %v", mode, settings.Database, err)
}
}
})
t.Run("Connection pool variables are read", func(t *testing.T) {
setPostgresEnv(t)
t.Setenv("DB_MAX_OPEN_CONNS", "4")
t.Setenv("DB_MAX_IDLE_CONNS", "2")
t.Setenv("DB_CONN_MAX_LIFETIME_SECONDS", "60")
settings, err := config.NewSettingsFromEnv()
database := settings.Database
if err != nil || database.MaxOpenConns != 4 || database.MaxIdleConns != 2 || database.ConnMaxLifetime != 60*time.Second {
t.Errorf("unexpected settings %+v, err %v", database, err)
}
})
t.Run("Data directory with PostgreSQL", func(t *testing.T) {
setPostgresEnv(t)
t.Setenv("DATA_DIR", "/data")
t.Setenv("DB_FILE", "./elsewhere/ignored.db")
settings, err := config.NewSettingsFromEnv()
if err != nil || strings.ReplaceAll(settings.DataDir(), "\\", "/") != "/data" {
t.Errorf("unexpected data dir %q err %v", settings.DataDir(), err)
}
})
}
func TestPostgresDSN(t *testing.T) {
t.Run("Connection string built from variables", func(t *testing.T) {
cases := []struct {
host, port, expectedPort, sslmode, expectedSslmode string
}{
{"db.local", "", "5432", "", "disable"},
{"db.local", "5433", "5433", "require", "require"},
{"pg", "5432", "5432", "verify-full", "verify-full"},
}
for _, tc := range cases {
t.Run(tc.host+":"+tc.port+" sslmode "+tc.sslmode, func(t *testing.T) {
port, sslmode := tc.port, tc.sslmode
if port == "" {
port = config.DefaultPostgresPort
}
if sslmode == "" {
sslmode = config.DefaultDatabaseSSLMode
}
dsn := database.PostgresDSN(database.PostgresConfig{Host: tc.host, Port: port, User: "app", Password: postgresTestPassword, Name: "cms", SSLMode: sslmode})
parsed, err := pgconn.ParseConfig(dsn)
if err != nil {
t.Fatalf("parse: %v", err)
}
if fmt.Sprintf("%s:%d", parsed.Host, parsed.Port) != tc.host+":"+tc.expectedPort || parsed.Database != "cms" || parsed.User != "app" {
t.Errorf("unexpected target %s:%d db %q user %q", parsed.Host, parsed.Port, parsed.Database, parsed.User)
}
if parsed.Password != postgresTestPassword {
t.Errorf("the password must survive the DSN, got %q", parsed.Password)
}
if parsed.RuntimeParams["timezone"] != "UTC" {
t.Errorf("the session time zone must be UTC, got %v", parsed.RuntimeParams)
}
if tc.expectedSslmode == "disable" && parsed.TLSConfig != nil {
t.Errorf("sslmode disable must not configure TLS")
}
if tc.expectedSslmode != "disable" && parsed.TLSConfig == nil {
t.Errorf("sslmode %s must configure TLS", tc.expectedSslmode)
}
if query := mustParseQuery(t, dsn); query.Get("sslmode") != tc.expectedSslmode {
t.Errorf("expected sslmode %q, got %q", tc.expectedSslmode, query.Get("sslmode"))
}
if parsed.ConnectTimeout <= 0 || parsed.ConnectTimeout > 10*time.Second {
t.Errorf("connect timeout must be short, got %v", parsed.ConnectTimeout)
}
})
}
})
}
func mustParseQuery(t *testing.T, dsn string) url.Values {
t.Helper()
parsed, err := url.Parse(dsn)
if err != nil {
t.Fatal(err)
}
return parsed.Query()
}
func TestPostgresFailFast(t *testing.T) {
t.Run("nothing listens on DB_PORT", func(t *testing.T) {
port := closedLocalPort(t)
dsn := database.PostgresDSN(database.PostgresConfig{Host: "127.0.0.1", Port: port, User: "app", Password: postgresTestPassword, Name: "cms", SSLMode: "disable"})
started := time.Now()
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: dsn})
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), "127.0.0.1:"+port) {
t.Errorf("the error must name the host and port: %v", err)
}
if strings.Contains(err.Error(), postgresTestPassword) {
t.Errorf("the error must not contain the password: %v", err)
}
if elapsed := time.Since(started); elapsed > 8*time.Second {
t.Errorf("startup must fail fast, took %v", elapsed)
}
})
t.Run("an unusable DSN is rejected without echoing it", func(t *testing.T) {
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: "postgres://user:" + postgresTestPassword + "@[broken"})
if err == nil || strings.Contains(err.Error(), postgresTestPassword) {
t.Errorf("unexpected error %v", err)
}
})
}
func TestPostgresStartupFailures(t *testing.T) {
binary := buildServerBinary(t, "")
t.Run("Missing PostgreSQL variable stops the server", func(t *testing.T) {
complete := map[string]string{"DB_HOST": "127.0.0.1", "DB_USER": "app", "DB_PASSWORD": postgresTestPassword, "DB_NAME": "cms"}
for missing := range complete {
t.Run(missing, func(t *testing.T) {
extra := []string{"DB_DRIVER=postgresql"}
for name, value := range complete {
if name != missing {
extra = append(extra, name+"="+value)
}
}
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t, extra...))
if code == 0 || !strings.Contains(output, "Environment variable "+missing+" is not set") || strings.Contains(output, postgresTestPassword) {
t.Errorf("code %d output %q", code, output)
}
})
}
})
t.Run("an invalid DB_SSLMODE stops the server naming the variable", func(t *testing.T) {
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
"DB_DRIVER=postgres", "DB_HOST=127.0.0.1", "DB_USER=app", "DB_PASSWORD="+postgresTestPassword, "DB_NAME=cms", "DB_SSLMODE=maybe"))
if code == 0 || !strings.Contains(output, "DB_SSLMODE") || strings.Contains(output, postgresTestPassword) {
t.Errorf("code %d output %q", code, output)
}
})
t.Run("Fail fast when the server cannot be used", func(t *testing.T) {
port := closedLocalPort(t)
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
"DB_DRIVER=postgres", "DB_HOST=127.0.0.1", "DB_PORT="+port, "DB_USER=app", "DB_PASSWORD="+postgresTestPassword, "DB_NAME=cms"))
if code == 0 || !strings.Contains(output, "Unable to open the database") {
t.Errorf("code %d output %q", code, output)
}
if !strings.Contains(output, "127.0.0.1:"+port) {
t.Errorf("the message must name the host and port: %q", output)
}
if strings.Contains(output, postgresTestPassword) {
t.Errorf("the message must not contain the password: %q", output)
}
})
t.Run("an omitted DB_PORT targets 5432", func(t *testing.T) {
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
"DB_DRIVER=postgres", "DB_HOST=127.0.0.1", "DB_USER=app", "DB_PASSWORD="+postgresTestPassword, "DB_NAME=cms"))
if code == 0 || strings.Contains(output, postgresTestPassword) {
t.Errorf("code %d output %q", code, output)
}
if strings.Contains(output, "Unable to open the database") && !strings.Contains(output, "127.0.0.1:5432") {
t.Errorf("the default port must be 5432: %q", output)
}
})
}