d59dcbe74e
Stand up the shared library with its first module: the Postgres plumbing every service currently copy-pastes — the DSN builder, the pool constructor, and the migration runner arrproxy proved out. - Add pg.DSNFromEnv, generalising the identical Sprintf builders in encapi, artifactapi and forgebot into one prefixed lookup with DATABASE_URL passthrough and libpq fallbacks. - Add pg.New and pg.NewMigrated, which ping before returning so an unreachable server fails at startup rather than on the first query. - Add pg.Migrate, lifting arrproxy's runner verbatim in semantics and generalising the hardcoded advisory-lock key to FNV-1a/64 of a caller-supplied name and the embedded set to an fs.FS. - Add pg/pgtest, unifying the encapi and artifactapi testcontainers helpers, with SkipIfShort so container-backed tests self-skip on the Docker-less Kubernetes runners. - Add the Makefile, README and pre-commit config, plus test, pre-commit and build pipelines on golib-ci.
237 lines
6.9 KiB
Go
237 lines
6.9 KiB
Go
package pg
|
|
|
|
import (
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// dsnVars is every variable DSNFromEnv reads, for both the bare and the
|
|
// prefixed namespace used by these tests. Each case starts from all of them
|
|
// unset so an inherited PGHOST on a developer's machine cannot change a result.
|
|
var dsnVars = []string{
|
|
"DATABASE_URL", "DBHOST", "DBPORT", "DBUSER", "DBPASS", "DBNAME", "DBSSL",
|
|
"PGHOST", "PGPORT", "PGUSER", "PGPASSWORD", "PGDATABASE", "PGSSLMODE",
|
|
"APP_DATABASE_URL", "APP_DBHOST", "APP_DBPORT", "APP_DBUSER", "APP_DBPASS",
|
|
"APP_DBNAME", "APP_DBSSL",
|
|
}
|
|
|
|
// setEnv clears every variable DSNFromEnv consults, then sets the given ones.
|
|
func setEnv(t *testing.T, env map[string]string) {
|
|
t.Helper()
|
|
for _, k := range dsnVars {
|
|
t.Setenv(k, "")
|
|
}
|
|
for k, v := range env {
|
|
t.Setenv(k, v)
|
|
}
|
|
}
|
|
|
|
func TestDSNFromEnv(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
prefix string
|
|
env map[string]string
|
|
want string
|
|
}{
|
|
{
|
|
name: "prefixed custom vars",
|
|
prefix: "APP_",
|
|
env: map[string]string{
|
|
"APP_DBHOST": "db.internal", "APP_DBPORT": "6432",
|
|
"APP_DBUSER": "app", "APP_DBPASS": "s3cret",
|
|
"APP_DBNAME": "appdb", "APP_DBSSL": "require",
|
|
},
|
|
want: "postgres://app:s3cret@db.internal:6432/appdb?sslmode=require",
|
|
},
|
|
{
|
|
name: "bare custom vars with an empty prefix",
|
|
prefix: "",
|
|
env: map[string]string{
|
|
"DBHOST": "pg", "DBPORT": "5432", "DBUSER": "encapi",
|
|
"DBPASS": "encapi", "DBNAME": "encapi", "DBSSL": "disable",
|
|
},
|
|
// Byte-identical to the fmt.Sprintf builder this replaces.
|
|
want: "postgres://encapi:encapi@pg:5432/encapi?sslmode=disable",
|
|
},
|
|
{
|
|
name: "libpq vars fill in",
|
|
prefix: "APP_",
|
|
env: map[string]string{
|
|
"PGHOST": "libpq.host", "PGPORT": "5433", "PGUSER": "pguser",
|
|
"PGPASSWORD": "pgpass", "PGDATABASE": "pgdb", "PGSSLMODE": "verify-full",
|
|
},
|
|
want: "postgres://pguser:pgpass@libpq.host:5433/pgdb?sslmode=verify-full",
|
|
},
|
|
{
|
|
name: "prefixed vars beat libpq vars per field",
|
|
prefix: "APP_",
|
|
env: map[string]string{
|
|
"APP_DBPASS": "from-secret",
|
|
"PGHOST": "libpq.host", "PGUSER": "pguser",
|
|
"PGPASSWORD": "ignored", "PGDATABASE": "pgdb",
|
|
},
|
|
want: "postgres://pguser:from-secret@libpq.host:5432/pgdb?sslmode=disable",
|
|
},
|
|
{
|
|
name: "defaults for host, port and sslmode",
|
|
prefix: "",
|
|
env: map[string]string{"DBUSER": "u", "DBNAME": "d"},
|
|
want: "postgres://u:@localhost:5432/d?sslmode=disable",
|
|
},
|
|
{
|
|
name: "prefixed DATABASE_URL passes through verbatim",
|
|
prefix: "APP_",
|
|
env: map[string]string{
|
|
"APP_DATABASE_URL": "postgres://who:cares@elsewhere/db?sslmode=require&application_name=x",
|
|
"APP_DBHOST": "ignored", "APP_DBUSER": "ignored", "APP_DBNAME": "ignored",
|
|
},
|
|
want: "postgres://who:cares@elsewhere/db?sslmode=require&application_name=x",
|
|
},
|
|
{
|
|
name: "bare DATABASE_URL wins over the field vars",
|
|
prefix: "APP_",
|
|
env: map[string]string{
|
|
"DATABASE_URL": "postgres://u:p@h:5432/d",
|
|
"APP_DBHOST": "ignored", "APP_DBUSER": "ignored", "APP_DBNAME": "ignored",
|
|
},
|
|
want: "postgres://u:p@h:5432/d",
|
|
},
|
|
{
|
|
name: "prefixed DATABASE_URL wins over the bare one",
|
|
prefix: "APP_",
|
|
env: map[string]string{
|
|
"APP_DATABASE_URL": "postgres://app@app-host/app",
|
|
"DATABASE_URL": "postgres://bare@bare-host/bare",
|
|
},
|
|
want: "postgres://app@app-host/app",
|
|
},
|
|
{
|
|
name: "reserved characters in the password are escaped",
|
|
prefix: "",
|
|
env: map[string]string{
|
|
"DBHOST": "h", "DBUSER": "u", "DBPASS": "p@ss/w:rd", "DBNAME": "d",
|
|
},
|
|
want: "postgres://u:p%40ss%2Fw%3Ard@h:5432/d?sslmode=disable",
|
|
},
|
|
{
|
|
name: "IPv6 host is bracketed",
|
|
prefix: "",
|
|
env: map[string]string{
|
|
"DBHOST": "fd00::1", "DBUSER": "u", "DBNAME": "d",
|
|
},
|
|
want: "postgres://u:@[fd00::1]:5432/d?sslmode=disable",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
setEnv(t, tc.env)
|
|
got, err := DSNFromEnv(tc.prefix)
|
|
if err != nil {
|
|
t.Fatalf("DSNFromEnv: %v", err)
|
|
}
|
|
if got != tc.want {
|
|
t.Fatalf("DSNFromEnv = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Every DSN this builds must survive the parse pgx will do on it.
|
|
func TestDSNFromEnv_ResultParses(t *testing.T) {
|
|
setEnv(t, map[string]string{
|
|
"DBHOST": "fd00::1", "DBPORT": "6432", "DBUSER": "us er",
|
|
"DBPASS": "p@ss/w:rd", "DBNAME": "app", "DBSSL": "verify-full",
|
|
})
|
|
dsn, err := DSNFromEnv("")
|
|
if err != nil {
|
|
t.Fatalf("DSNFromEnv: %v", err)
|
|
}
|
|
u, err := url.Parse(dsn)
|
|
if err != nil {
|
|
t.Fatalf("parse %q: %v", dsn, err)
|
|
}
|
|
if u.Hostname() != "fd00::1" {
|
|
t.Errorf("host = %q, want fd00::1", u.Hostname())
|
|
}
|
|
if u.Port() != "6432" {
|
|
t.Errorf("port = %q, want 6432", u.Port())
|
|
}
|
|
if u.User.Username() != "us er" {
|
|
t.Errorf("user = %q, want %q", u.User.Username(), "us er")
|
|
}
|
|
pass, _ := u.User.Password()
|
|
if pass != "p@ss/w:rd" {
|
|
t.Errorf("password = %q, want %q", pass, "p@ss/w:rd")
|
|
}
|
|
if got := strings.TrimPrefix(u.Path, "/"); got != "app" {
|
|
t.Errorf("database = %q, want app", got)
|
|
}
|
|
if got := u.Query().Get("sslmode"); got != "verify-full" {
|
|
t.Errorf("sslmode = %q, want verify-full", got)
|
|
}
|
|
}
|
|
|
|
func TestDSNFromEnv_Errors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
env map[string]string
|
|
wantSub string
|
|
}{
|
|
{
|
|
name: "unparseable port",
|
|
env: map[string]string{"DBPORT": "not-a-port", "DBUSER": "u", "DBNAME": "d"},
|
|
wantSub: "invalid DBPORT",
|
|
},
|
|
{
|
|
name: "port out of range",
|
|
env: map[string]string{"DBPORT": "70000", "DBUSER": "u", "DBNAME": "d"},
|
|
wantSub: "out of range",
|
|
},
|
|
{
|
|
name: "no user",
|
|
env: map[string]string{"DBNAME": "d"},
|
|
wantSub: "set DBUSER or PGUSER",
|
|
},
|
|
{
|
|
name: "no database name",
|
|
env: map[string]string{"DBUSER": "u"},
|
|
wantSub: "set DBNAME or PGDATABASE",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
setEnv(t, tc.env)
|
|
got, err := DSNFromEnv("")
|
|
if err == nil {
|
|
t.Fatalf("expected an error, got DSN %q", got)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantSub) {
|
|
t.Fatalf("error %q does not mention %q", err, tc.wantSub)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The error names the prefixed variable the caller is expected to set, not the
|
|
// bare one, or the message sends them looking for the wrong knob.
|
|
func TestDSNFromEnv_ErrorNamesPrefixedVar(t *testing.T) {
|
|
setEnv(t, map[string]string{"APP_DBPORT": "x", "APP_DBUSER": "u", "APP_DBNAME": "d"})
|
|
_, err := DSNFromEnv("APP_")
|
|
if err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
if !strings.Contains(err.Error(), "APP_DBPORT") {
|
|
t.Fatalf("error %q does not name APP_DBPORT", err)
|
|
}
|
|
}
|
|
|
|
func TestDSN_EmptyPasswordMatchesLegacyFormat(t *testing.T) {
|
|
// The Sprintf builders rendered an unset password as an empty string
|
|
// between the colon and the "@"; keep that shape so DSNs do not churn.
|
|
if got, want := DSN("h", 5432, "u", "", "d", "disable"), "postgres://u:@h:5432/d?sslmode=disable"; got != want {
|
|
t.Fatalf("DSN = %q, want %q", got, want)
|
|
}
|
|
}
|