feat: PostgreSQL driver support
This commit is contained in:
1 parent
eb9bfabed5
commit
dc5d12348c
24 files changed
+1585
-75
No files matched your search
@@ -0,0 +1,337 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const postgresTestDSNVariable = "RENEWCMS_TEST_POSTGRES_DSN"
|
||||
|
||||
var postgresTestDatabaseCounter atomic.Int64
|
||||
|
||||
type postgresTestServer struct {
|
||||
admin *sql.DB
|
||||
config database.PostgresConfig
|
||||
}
|
||||
|
||||
func connectPostgresTestServer(t *testing.T) *postgresTestServer {
|
||||
t.Helper()
|
||||
dsn := os.Getenv(postgresTestDSNVariable)
|
||||
if dsn == "" {
|
||||
t.Skip(postgresTestDSNVariable + " is not set; run task test:postgres to test against a real server")
|
||||
}
|
||||
parsed, err := pgconn.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", postgresTestDSNVariable, err)
|
||||
}
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open admin: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = admin.Close() })
|
||||
return &postgresTestServer{admin: admin, config: database.PostgresConfig{
|
||||
Host: parsed.Host, Port: fmt.Sprint(parsed.Port), User: parsed.User, Password: parsed.Password, SSLMode: "disable",
|
||||
}}
|
||||
}
|
||||
|
||||
func (s *postgresTestServer) createDatabase(t *testing.T) database.PostgresConfig {
|
||||
t.Helper()
|
||||
name := fmt.Sprintf("renewcms_test_%d_%d", time.Now().UnixNano()%1_000_000_000, postgresTestDatabaseCounter.Add(1))
|
||||
if _, err := s.admin.Exec("CREATE DATABASE " + name); err != nil {
|
||||
t.Fatalf("create database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = s.admin.Exec("DROP DATABASE IF EXISTS " + name + " WITH (FORCE)") })
|
||||
config := s.config
|
||||
config.Name = name
|
||||
return config
|
||||
}
|
||||
|
||||
func (s *postgresTestServer) connect(t *testing.T, config database.PostgresConfig) *database.Connection {
|
||||
t.Helper()
|
||||
connection, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(config)})
|
||||
if err != nil {
|
||||
t.Fatalf("connect postgres: %v", err)
|
||||
}
|
||||
connection.DB.Logger = logger.Discard
|
||||
t.Cleanup(func() { _ = connection.Close() })
|
||||
return connection
|
||||
}
|
||||
|
||||
func openContractPostgres(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
server := connectPostgresTestServer(t)
|
||||
connection := server.connect(t, server.createDatabase(t))
|
||||
if err := database.Migrate(connection.DB); err != nil {
|
||||
t.Fatalf("migrate postgres: %v", err)
|
||||
}
|
||||
return connection.DB
|
||||
}
|
||||
|
||||
func migratedPostgres(t *testing.T) (*postgresTestServer, database.PostgresConfig, *gorm.DB) {
|
||||
t.Helper()
|
||||
server := connectPostgresTestServer(t)
|
||||
config := server.createDatabase(t)
|
||||
connection := server.connect(t, config)
|
||||
if err := database.Migrate(connection.DB); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return server, config, connection.DB
|
||||
}
|
||||
|
||||
func TestPostgresRepositoryContract(t *testing.T) {
|
||||
runRepositoryContract(t, contractBackend{name: "postgres", open: openContractPostgres})
|
||||
}
|
||||
|
||||
func TestPostgresConnectionPool(t *testing.T) {
|
||||
t.Run("Connection pool", func(t *testing.T) {
|
||||
server := connectPostgresTestServer(t)
|
||||
pool := database.PoolSettings{MaxOpenConns: 4, MaxIdleConns: 2, ConnMaxLifetime: 60 * time.Second}
|
||||
connection, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(server.createDatabase(t)), Pool: &pool})
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
sqlDb, _ := connection.DB.DB()
|
||||
|
||||
stats := sqlDb.Stats()
|
||||
|
||||
if stats.MaxOpenConnections != 4 {
|
||||
t.Errorf("expected 4 open connections at most, got %d", stats.MaxOpenConnections)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostgresServerFaults(t *testing.T) {
|
||||
t.Run("the password is wrong", func(t *testing.T) {
|
||||
server := connectPostgresTestServer(t)
|
||||
config := server.createDatabase(t)
|
||||
config.Password = "definitely/wrong p@ss"
|
||||
|
||||
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(config)})
|
||||
|
||||
expectedAddress := net.JoinHostPort(config.Host, config.Port)
|
||||
if err == nil || !strings.Contains(err.Error(), expectedAddress) || strings.Contains(err.Error(), config.Password) {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the database does not exist", func(t *testing.T) {
|
||||
server := connectPostgresTestServer(t)
|
||||
config := server.config
|
||||
config.Name = "renewcms_missing_database"
|
||||
|
||||
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(config)})
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), net.JoinHostPort(config.Host, config.Port)) || strings.Contains(err.Error(), config.Password) {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostgresMigration(t *testing.T) {
|
||||
t.Run("Migration on PostgreSQL", func(t *testing.T) {
|
||||
_, _, db := migratedPostgres(t)
|
||||
|
||||
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
|
||||
count := columnCount(t, db, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?", table)
|
||||
if count != 1 {
|
||||
t.Errorf("table %s must exist in schema public", table)
|
||||
}
|
||||
}
|
||||
uniqueColumns := map[string][]string{
|
||||
"users": {"username", "email", "password_reset_code", "verification_code"},
|
||||
"api_keys": {"hashed_key"},
|
||||
}
|
||||
for table, columns := range uniqueColumns {
|
||||
for _, column := range columns {
|
||||
count := columnCount(t, db, `SELECT COUNT(*) FROM pg_index i
|
||||
JOIN pg_class c ON c.oid = i.indrelid
|
||||
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = i.indkey[0]
|
||||
WHERE c.relname = ? AND a.attname = ? AND i.indisunique AND array_length(i.indkey, 1) = 1`, table, column)
|
||||
if count == 0 {
|
||||
t.Errorf("a unique index on %s.%s must exist", table, column)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("migrating twice changes nothing", func(t *testing.T) {
|
||||
_, _, db := migratedPostgres(t)
|
||||
before := columnCount(t, db, "SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'")
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Errorf("the second migration must succeed: %v", err)
|
||||
}
|
||||
|
||||
if after := columnCount(t, db, "SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'"); after != before {
|
||||
t.Errorf("indexes changed from %d to %d", before, after)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the first user lacking a role becomes owner and the others admin", func(t *testing.T) {
|
||||
_, _, db := migratedPostgres(t)
|
||||
for _, name := range []string{"first", "second", "third"} {
|
||||
insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', true, 0)", name, name+"@x.example")
|
||||
if insert.Error != nil {
|
||||
t.Fatalf("insert %s: %v", name, insert.Error)
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
roles := map[string]string{}
|
||||
var users []models.User
|
||||
db.Find(&users)
|
||||
for _, user := range users {
|
||||
roles[user.Username] = user.Role
|
||||
}
|
||||
if roles["first"] != "owner" || roles["second"] != "admin" || roles["third"] != "admin" {
|
||||
t.Errorf("unexpected roles %v", roles)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostgresTimes(t *testing.T) {
|
||||
t.Run("times are stored and read back in UTC", func(t *testing.T) {
|
||||
_, _, db := migratedPostgres(t)
|
||||
repos := newContractRepositories(db)
|
||||
zone := time.FixedZone("plus2", 2*3600)
|
||||
expiration := time.Date(2030, 5, 6, 7, 8, 9, 0, zone)
|
||||
user := contractUser("zoned", "zoned@x.example")
|
||||
user.VerificationExpiration = expiration
|
||||
|
||||
created := mustCreateUser(t, repos, user)
|
||||
read, err := repos.users.Get(created.ID)
|
||||
|
||||
if err != nil || !read.VerificationExpiration.Equal(expiration) || read.VerificationExpiration.Location() != time.UTC {
|
||||
t.Errorf("expected %v in UTC, got %v (%v) err %v", expiration, read.VerificationExpiration, read.VerificationExpiration.Location(), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostgresTimesInLists(t *testing.T) {
|
||||
_, _, db := migratedPostgres(t)
|
||||
repos := newContractRepositories(db)
|
||||
zone := time.FixedZone("plus2", 2*3600)
|
||||
for _, name := range []string{"first", "second"} {
|
||||
user := contractUser(name, name+"@x.example")
|
||||
user.VerificationExpiration = time.Date(2030, 5, 6, 7, 8, 9, 0, zone)
|
||||
mustCreateUser(t, repos, user)
|
||||
}
|
||||
|
||||
users := repos.users.GetAll()
|
||||
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("expected 2 users, got %d", len(users))
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.VerificationExpiration.Location() != time.UTC {
|
||||
t.Errorf("%s: expected UTC, got %v", user.Username, user.VerificationExpiration.Location())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupOfIdentityAndBinaryColumns(t *testing.T) {
|
||||
server, _, db := migratedPostgres(t)
|
||||
if err := db.Exec(`CREATE TABLE extras (id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, payload bytea, label text DEFAULT 'x' NOT NULL, amount numeric(10,2))`).Error; err != nil {
|
||||
t.Fatalf("create extras: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO extras (payload, amount) VALUES (decode('00ff27', 'hex'), 12.50), (NULL, NULL)`).Error; err != nil {
|
||||
t.Fatalf("insert extras: %v", err)
|
||||
}
|
||||
path, err := database.NewPostgresBackup(db, filepath.Join(t.TempDir(), "backups"), time.Now).Backup()
|
||||
if err != nil {
|
||||
t.Fatalf("backup: %v", err)
|
||||
}
|
||||
content, _ := os.ReadFile(path)
|
||||
dump := string(content)
|
||||
|
||||
for _, fragment := range []string{
|
||||
`"id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL`,
|
||||
`"label" text DEFAULT 'x'::text NOT NULL`,
|
||||
`'\x00ff27'`,
|
||||
`12.50`,
|
||||
`NULL`,
|
||||
} {
|
||||
if !strings.Contains(dump, fragment) {
|
||||
t.Errorf("the dump must contain %s", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
restoredConfig := server.createDatabase(t)
|
||||
replayPostgresDump(t, restoredConfig, dump)
|
||||
restored := server.connect(t, restoredConfig)
|
||||
var payload []byte
|
||||
if err := restored.DB.Raw(`SELECT payload FROM extras WHERE id = 1`).Row().Scan(&payload); err != nil || len(payload) != 3 || payload[1] != 0xff {
|
||||
t.Errorf("unexpected restored payload %v err %v", payload, err)
|
||||
}
|
||||
var next int
|
||||
if err := restored.DB.Raw(`INSERT INTO extras (label) VALUES ('y') RETURNING id`).Row().Scan(&next); err != nil || next != 3 {
|
||||
t.Errorf("the identity sequence must continue after the restored rows, got %d err %v", next, err)
|
||||
}
|
||||
}
|
||||
|
||||
func populateForPostgresBackup(t *testing.T, repos contractRepositories) {
|
||||
t.Helper()
|
||||
trickyName := "o'brien \"quoted\" back\\slash\nline é😀"
|
||||
mustCreateUser(t, repos, contractUser(trickyName, "tricky@x.example"))
|
||||
second := contractUser("plain", "plain@x.example")
|
||||
second.PasswordResetCode = "reset-code"
|
||||
mustCreateUser(t, repos, second)
|
||||
if _, err := repos.articles.Create(domainArticle.Article{Title: "Title", Body: "line1\nline2 'q' \"d\" \\ end", IsOnline: true}); err != nil {
|
||||
t.Fatalf("create article: %v", err)
|
||||
}
|
||||
mustCreateImage(t, repos, "pic.png", strings.Repeat("c", 64))
|
||||
if _, err := repos.invites.Create(domainInvite.Invite{Email: "i@x.example", HashedCode: "h", ExpiresAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC), InvitedBy: 1}); err != nil {
|
||||
t.Fatalf("create invite: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackup(t *testing.T) {
|
||||
t.Run("Backup before an update", func(t *testing.T) {
|
||||
_, _, db := migratedPostgres(t)
|
||||
populateForPostgresBackup(t, newContractRepositories(db))
|
||||
directory := filepath.Join(t.TempDir(), "backups")
|
||||
backup := database.NewPostgresBackup(db, directory, steppingClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
var paths []string
|
||||
for range 5 {
|
||||
path, err := backup.Backup()
|
||||
if err != nil {
|
||||
t.Fatalf("backup: %v", err)
|
||||
}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(directory)
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("only the 3 newest backups are kept, got %d", len(entries))
|
||||
}
|
||||
for _, path := range paths[2:] {
|
||||
if filepath.Ext(path) != ".sql" || !strings.HasPrefix(filepath.Base(path), "renewcms-") {
|
||||
t.Errorf("unexpected backup name %q", path)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("the newest backup %q must remain: %v", path, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a failure returns an error and leaves no file", func(t *testing.T) {
|
||||
server := connectPostgresTestServer(t)
|
||||
connection := server.connect(t, server.createDatabase(t))
|
||||
directory := filepath.Join(t.TempDir(), "backups")
|
||||
backup := database.NewPostgresBackup(connection.DB, directory, time.Now)
|
||||
if err := connection.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
path, err := backup.Backup()
|
||||
|
||||
if err == nil || path != "" {
|
||||
t.Errorf("expected a failure, got %q %v", path, err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(directory); len(entries) != 0 {
|
||||
t.Errorf("no file may remain, got %d entries", len(entries))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PostgreSQL backup content", func(t *testing.T) {
|
||||
server, _, db := migratedPostgres(t)
|
||||
populateForPostgresBackup(t, newContractRepositories(db))
|
||||
directory := filepath.Join(t.TempDir(), "backups")
|
||||
|
||||
path, err := database.NewPostgresBackup(db, directory, time.Now).Backup()
|
||||
if err != nil {
|
||||
t.Fatalf("backup: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dump := string(content)
|
||||
|
||||
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
|
||||
if !strings.Contains(dump, `CREATE TABLE "`+table+`"`) {
|
||||
t.Errorf("the dump must contain the schema of %s", table)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(dump, "CREATE UNIQUE INDEX") {
|
||||
t.Errorf("the dump must contain the indexes")
|
||||
}
|
||||
for _, table := range []string{"articles", "images", "users", "invites"} {
|
||||
if !strings.Contains(dump, `INSERT INTO "`+table+`"`) {
|
||||
t.Errorf("the dump must contain rows of %s", table)
|
||||
}
|
||||
}
|
||||
if password := server.config.Password; password != "" && strings.Contains(dump, password) {
|
||||
t.Errorf("the dump must not contain the DB_PASSWORD value")
|
||||
}
|
||||
|
||||
restoredConfig := server.createDatabase(t)
|
||||
replayPostgresDump(t, restoredConfig, dump)
|
||||
restored := server.connect(t, restoredConfig)
|
||||
restoredPath, err := database.NewPostgresBackup(restored.DB, filepath.Join(t.TempDir(), "again"), time.Now).Backup()
|
||||
if err != nil {
|
||||
t.Fatalf("backup of the restored database: %v", err)
|
||||
}
|
||||
restoredContent, _ := os.ReadFile(restoredPath)
|
||||
if line := firstDifferingLine(dump, string(restoredContent)); line != "" {
|
||||
t.Errorf("replaying the dump must restore the same schema and rows, first difference: %s", line)
|
||||
}
|
||||
restoredRepos := newContractRepositories(restored.DB)
|
||||
restoredUsers := restoredRepos.users.GetAll()
|
||||
if len(restoredUsers) != 2 || restoredUsers[0].Username != "o'brien \"quoted\" back\\slash\nline é😀" || restoredUsers[1].PasswordResetCode != "reset-code" {
|
||||
t.Errorf("unexpected restored users %+v", restoredUsers)
|
||||
}
|
||||
restoredArticle, _ := restoredRepos.articles.Get(1)
|
||||
if restoredArticle.Body != "line1\nline2 'q' \"d\" \\ end" {
|
||||
t.Errorf("unexpected restored article body %q", restoredArticle.Body)
|
||||
}
|
||||
next := mustCreateUser(t, restoredRepos, contractUser("third", "third@x.example"))
|
||||
if next.ID != 3 {
|
||||
t.Errorf("sequences must be reset so the next insert gets an unused id, got %d", next.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func replayPostgresDump(t *testing.T, config database.PostgresConfig, dump string) {
|
||||
t.Helper()
|
||||
replay, err := sql.Open("pgx", database.PostgresDSN(config))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer replay.Close()
|
||||
if _, err := replay.Exec(dump); err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresUsersAreCaseSensitiveThroughTheDomain(t *testing.T) {
|
||||
t.Run("Bob and bob can both register", func(t *testing.T) {
|
||||
_, _, db := migratedPostgres(t)
|
||||
repos := newContractRepositories(db)
|
||||
mustCreateUser(t, repos, contractUser("Bob", "b1@x.example"))
|
||||
|
||||
other, err := repos.users.Create(contractUser("bob", "b2@x.example"))
|
||||
_, missing := repos.users.GetByUsername("BOB")
|
||||
|
||||
if err != nil || other.Username != "bob" || missing == nil {
|
||||
t.Errorf("created %+v err %v, uppercase lookup err %v", other, err, missing)
|
||||
}
|
||||
if _, err := repos.users.GetByEmail("B1@X.EXAMPLE"); err == nil {
|
||||
t.Error("emails are compared exactly like SQLite does")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user