Fourth section of #23. db-dsn now names the console's runtime user, and the new db-migrate-dsn (or db-migrate-dsn-file) names the user that runs migrations. start opens both, migrates on the migration connection, grants console_runtime to the runtime user there (WITH INHERIT TRUE, the name quoted by the database), closes it, and serves on the runtime pool alone. With db-migrate-dsn left out, start runs no migration and refuses when the schema is behind its code, which is how a host runs migrations as a separate step; migrate up, down and status use the migration string and refuse without it, with no fallback to db-dsn. Both pools are opened from one pgx.ParseConfig: the migration pool's search_path is public and both pools' role is none, set in the startup parameters after every key of either name is removed case-insensitively, so neither a setting the database or role carries nor a key the string repeats in another case moves a session off its login or its path. PendingMigrations reads each stream's ledger by qualified name inside a transaction pinned to search_path pg_catalog, pg_temp. The test stack runs on two users: postgres-seed creates member_console_runtime in new and existing volumes, bootstrap-stack.sh writes MC_DB_DSN as that user and MC_DB_MIGRATE_DSN as member_console, and the four scripts refuse a .env without the migration string, with the command that regenerates it. Scripts and fixtures that need a superuser on the application database (the reset, the screens' timestamp pinning) use the migration string. This worktree's stack was regenerated with that command and the console booted as member_console_runtime: its sessions were the runtime user's only, no migration-user session stayed open, and the runtime user could not insert into core.pool_provisions. Full suite on reset databases: 4277 pass, 15 skip, none failed. The walkthroughs' names, results and skip messages match the previous section's run. make screens lists three changed screens, all data: regenerated ids on operator-person-detail and operator-billing-invoice-detail (the demo snapshot was rebuilt) and a domain's age on operator-domains (294d to 295d); the baseline was not accepted. make lint passes. Deliberate breaks, scripts/mutation-check -side new, all CAUGHT (36): dbRuntime runs the migrations on the runtime pool dbRuntime skips the boot grant dbRuntime leaves the migration pool open dbRuntime skips the pending check when db-migrate-dsn is unset dbRuntime migrates on db-dsn when db-migrate-dsn is unset GrantRuntimeRole without WITH INHERIT TRUE GrantRuntimeRole with %s in place of %I PendingMigrations takes a missing ledger as current PendingMigrations resolves the ledger without the public. qualifier the migration pool opened without search_path=public the migration pool opened without role=none the runtime pool opened without role=none ConnectMigration keeps the search_path and role the string carries dbMigration falls back to db-dsn ValidateStart accepts a malformed db-migrate-dsn db-migrate-dsn-file is not a database secret pair db-migrate-dsn-file is not a database secret pair (the migrate commands read db-dsn-file only) the bootstrap guard removed reset-test-db.sh guard removed seed-demo.sh guard removed reset-app-db.sh guard after its first psql startup parameters match their keys case-sensitively the migration pool opened without role=none (keys and options in the string) the runtime pool opened without role=none (keys and options in the string) the runtime pool replaces the search_path the string carries the string's spaces are written as plus signs ValidateStart reads db-migrate-dsn as set whatever it holds PendingMigrations lists only the versions the ledger shows when it is missing migrationDSN keeps whitespace migrate up accepts a whitespace-only db-migrate-dsn runtimeDSN accepts a whitespace-only db-dsn openDB runs the migrating boot for an unknown mode bootstrap-stack.sh trusts the exported MC_DB_MIGRATE_DSN reset-test-db.sh trusts the exported MC_DB_MIGRATE_DSN reset-app-db.sh trusts the exported MC_DB_MIGRATE_DSN seed-demo.sh trusts the exported MC_DB_MIGRATE_DSN
179 lines
6.0 KiB
Go
179 lines
6.0 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// databaseCommandPath is one way a command opens the database. run executes
|
|
// the path and returns the error it ends with.
|
|
type databaseCommandPath struct {
|
|
name string
|
|
run func() error
|
|
// fileKey is the -file key the command reads, and unset is the refusal
|
|
// it ends with when neither the key nor its direct variant is set.
|
|
fileKey string
|
|
unset string
|
|
}
|
|
|
|
const (
|
|
dbDSNNotSet = "db-dsn is not set (MC_DB_DSN or MC_DB_DSN_FILE)"
|
|
dbMigrateDSNNotSet = "member-console migrate needs db-migrate-dsn (MC_DB_MIGRATE_DSN or MC_DB_MIGRATE_DSN_FILE)"
|
|
)
|
|
|
|
func databaseCommandPaths() []databaseCommandPath {
|
|
return []databaseCommandPath{
|
|
{"migrate up", func() error { return migrateUpCmd.RunE(migrateUpCmd, nil) }, "db-migrate-dsn-file", dbMigrateDSNNotSet},
|
|
{"migrate down", func() error { return migrateDownCmd.RunE(migrateDownCmd, nil) }, "db-migrate-dsn-file", dbMigrateDSNNotSet},
|
|
{"migrate status", func() error { return migrateStatusCmd.RunE(migrateStatusCmd, nil) }, "db-migrate-dsn-file", dbMigrateDSNNotSet},
|
|
{"connectSettings", func() error {
|
|
_, _, _, err := connectSettings()
|
|
return err
|
|
}, "db-dsn-file", dbDSNNotSet},
|
|
{"seed-demo", func() error { return seedDemoCmd.RunE(&cobra.Command{}, nil) }, "db-dsn-file", dbDSNNotSet},
|
|
}
|
|
}
|
|
|
|
// closedPort returns a loopback port that nothing listens on.
|
|
func closedPort(t *testing.T) string {
|
|
t.Helper()
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
port := strconv.Itoa(l.Addr().(*net.TCPAddr).Port)
|
|
if err := l.Close(); err != nil {
|
|
t.Fatalf("close listener: %v", err)
|
|
}
|
|
return port
|
|
}
|
|
|
|
// checkFileKeyRead sets only the command's file key and asserts that the path read the
|
|
// file: its error is the connection failure to the file's port.
|
|
func checkFileKeyRead(t *testing.T, p databaseCommandPath, port string) {
|
|
t.Helper()
|
|
viper.Reset()
|
|
t.Cleanup(viper.Reset)
|
|
|
|
path := filepath.Join(t.TempDir(), p.fileKey)
|
|
dsn := "postgres://u:p@127.0.0.1:" + port + "/x?connect_timeout=1"
|
|
if err := os.WriteFile(path, []byte(dsn+"\n"), 0o600); err != nil {
|
|
t.Fatalf("write fixture file: %v", err)
|
|
}
|
|
viper.Set(p.fileKey, path)
|
|
|
|
err := p.run()
|
|
if err == nil {
|
|
t.Fatalf("%s: want a connection failure, got nil", p.name)
|
|
}
|
|
if strings.Contains(err.Error(), p.unset) {
|
|
t.Errorf("%s: error = %q, want a connection failure, not the unset refusal", p.name, err.Error())
|
|
}
|
|
if !strings.Contains(err.Error(), port) {
|
|
t.Errorf("%s: error = %q, want a connection failure that names port %s", p.name, err.Error(), port)
|
|
}
|
|
}
|
|
|
|
// checkUnsetRefused sets neither key and asserts the unset refusal.
|
|
func checkUnsetRefused(t *testing.T, p databaseCommandPath) {
|
|
t.Helper()
|
|
viper.Reset()
|
|
t.Cleanup(viper.Reset)
|
|
|
|
err := p.run()
|
|
if err == nil {
|
|
t.Fatalf("%s: want the unset refusal, got nil", p.name)
|
|
}
|
|
if !strings.Contains(err.Error(), p.unset) {
|
|
t.Errorf("%s: error = %q, want it to contain %q", p.name, err.Error(), p.unset)
|
|
}
|
|
}
|
|
|
|
// checkWhitespaceRefused sets the command's direct key to whitespace only and
|
|
// asserts the unset refusal: pgx would otherwise connect with libpq's defaults.
|
|
func checkWhitespaceRefused(t *testing.T, p databaseCommandPath) {
|
|
t.Helper()
|
|
viper.Reset()
|
|
t.Cleanup(viper.Reset)
|
|
viper.Set(strings.TrimSuffix(p.fileKey, "-file"), " \t")
|
|
|
|
err := p.run()
|
|
if err == nil {
|
|
t.Fatalf("%s: want the unset refusal, got nil", p.name)
|
|
}
|
|
if !strings.Contains(err.Error(), p.unset) {
|
|
t.Errorf("%s: error = %q, want it to contain %q", p.name, err.Error(), p.unset)
|
|
}
|
|
}
|
|
|
|
// TestDatabaseCommandsReadTheFileKeys covers task 2.4 (design D12): every
|
|
// command that opens the database resolves its file key first, and one that
|
|
// finds no connection string refuses instead of letting the driver fall back
|
|
// to its defaults.
|
|
func TestDatabaseCommandsReadTheFileKeys(t *testing.T) {
|
|
// viper.Reset drops the environment binding, so the rows never read
|
|
// MC_DB_DSN or MC_DB_MIGRATE_DSN; blanking them as well keeps migrate down,
|
|
// which this test runs, off a real database even if that ever changes.
|
|
t.Setenv("MC_DB_DSN", "")
|
|
t.Setenv("MC_DB_DSN_FILE", "")
|
|
t.Setenv("MC_DB_MIGRATE_DSN", "")
|
|
t.Setenv("MC_DB_MIGRATE_DSN_FILE", "")
|
|
t.Setenv("MC_DB_MIGRATE_DSN", "")
|
|
t.Setenv("MC_DB_MIGRATE_DSN_FILE", "")
|
|
port := closedPort(t)
|
|
for _, p := range databaseCommandPaths() {
|
|
t.Run("file key read: "+p.name, func(t *testing.T) {
|
|
checkFileKeyRead(t, p, port)
|
|
})
|
|
}
|
|
for _, p := range databaseCommandPaths() {
|
|
t.Run("neither key set: "+p.name, func(t *testing.T) {
|
|
checkUnsetRefused(t, p)
|
|
})
|
|
}
|
|
for _, p := range databaseCommandPaths() {
|
|
t.Run("whitespace only: "+p.name, func(t *testing.T) {
|
|
checkWhitespaceRefused(t, p)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMigrationDSNIsUnsetWhenOnlyWhitespace covers the read start and
|
|
// seed-demo make of db-migrate-dsn: the boot migrates only when it holds a
|
|
// string, and ValidateStart already treats whitespace as none.
|
|
func TestMigrationDSNIsUnsetWhenOnlyWhitespace(t *testing.T) {
|
|
viper.Reset()
|
|
t.Cleanup(viper.Reset)
|
|
viper.Set("db-migrate-dsn", " \n")
|
|
if got := migrationDSN(); got != "" {
|
|
t.Errorf("migrationDSN() = %q, want it unset", got)
|
|
}
|
|
viper.Set("db-migrate-dsn", " postgres://u@h/db ")
|
|
if got := migrationDSN(); got != "postgres://u@h/db" {
|
|
t.Errorf("migrationDSN() = %q, want the string without its whitespace", got)
|
|
}
|
|
}
|
|
|
|
// TestOpenDBRefusesAnUnknownMode covers the mode switch: a mode that no case
|
|
// names opens nothing, instead of running the migrating boot.
|
|
func TestOpenDBRefusesAnUnknownMode(t *testing.T) {
|
|
viper.Reset()
|
|
t.Cleanup(viper.Reset)
|
|
_, _, err := openDB(context.Background(), slog.New(slog.DiscardHandler), dbMode(99))
|
|
if err == nil || !strings.Contains(err.Error(), "unknown database mode 99") {
|
|
t.Errorf("openDB error = %v, want unknown database mode 99", err)
|
|
}
|
|
}
|