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
105 lines
3.7 KiB
Go
105 lines
3.7 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// staleEnvRefusal is the stack scripts' refusal of a test/.env that lacks
|
|
// MC_DB_MIGRATE_DSN (design copy 28), for the .env at envFile.
|
|
func staleEnvRefusal(envFile string) string {
|
|
return "error: MC_DB_MIGRATE_DSN is not set in " + envFile + ".\n" +
|
|
"\n" +
|
|
"This .env predates the stack's runtime database user. Regenerate it:\n" +
|
|
"\n" +
|
|
" cd test && docker compose down && rm .env && ./bootstrap-stack.sh && docker compose up -d\n" +
|
|
"\n" +
|
|
"Volumes survive, and postgres-seed adds the runtime user to the existing cluster.\n"
|
|
}
|
|
|
|
// exportedMigrateDSN is the entry of a shell that sourced another worktree's
|
|
// test/.env.
|
|
const exportedMigrateDSN = "MC_DB_MIGRATE_DSN=postgres://x@h/db"
|
|
|
|
// TestScriptsRefuseAnEnvWithoutTheMigrationDSN runs each script that reads
|
|
// test/.env beside a .env that holds every other key the script reads and
|
|
// lacks MC_DB_MIGRATE_DSN, with the shims of TestScriptsRefuseUnknownArguments.
|
|
// Each must print the regeneration command on stderr, exit 1, print nothing on
|
|
// stdout, and reach no database or container: a guard that runs after the
|
|
// script's first psql, go or docker call shows as a shim that ran. The
|
|
// bootstrap script asks git for the repository's top first, so its one
|
|
// permitted shim is git. Each also runs in a shell that exports
|
|
// MC_DB_MIGRATE_DSN: the file is what the script reads, so the exported value
|
|
// must not stand in for the key the file lacks.
|
|
func TestScriptsRefuseAnEnvWithoutTheMigrationDSN(t *testing.T) {
|
|
for _, c := range []struct {
|
|
script string
|
|
env string
|
|
ran string
|
|
}{
|
|
{"reset-test-db.sh",
|
|
"POSTGRES_ADMIN_DSN=postgres://u:p@localhost:5432/postgres\n" +
|
|
"TEST_DATABASE_URL=postgres://u:p@localhost:5432/member_console_test\n" +
|
|
"E2E_DATABASE_URL=postgres://u:p@localhost:5432/member_console_e2e\n", ""},
|
|
{"reset-app-db.sh",
|
|
"POSTGRES_ADMIN_DSN=postgres://u:p@localhost:5432/postgres\n" +
|
|
"MC_DB_DSN=postgres://r:p@localhost:5432/member_console\n", ""},
|
|
{"seed-demo.sh", "MC_DB_DSN=postgres://r:p@localhost:5432/member_console\n", ""},
|
|
} {
|
|
t.Run(c.script, func(t *testing.T) {
|
|
sb := newScriptBox(t, c.script)
|
|
envFile := filepath.Join(sb.dir, ".env")
|
|
if err := os.WriteFile(envFile, []byte(c.env), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checkStaleEnvRefused(t, sb, envFile, c.ran)
|
|
sb.env = []string{exportedMigrateDSN}
|
|
checkStaleEnvRefused(t, sb, envFile, c.ran)
|
|
})
|
|
}
|
|
t.Run("bootstrap-stack.sh", func(t *testing.T) {
|
|
sb := newFarmNameBox(t, "", "")
|
|
envFile := filepath.Join(sb.dir, "repo", "test", ".env")
|
|
// The farm-name fixture holds every key bootstrap reads once it has
|
|
// found the .env; drop the one under test.
|
|
env, err := os.ReadFile(envFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var kept []string
|
|
for _, line := range strings.Split(string(env), "\n") {
|
|
if !strings.HasPrefix(line, "MC_DB_MIGRATE_DSN=") {
|
|
kept = append(kept, line)
|
|
}
|
|
}
|
|
if err := os.WriteFile(envFile, []byte(strings.Join(kept, "\n")), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checkStaleEnvRefused(t, sb, envFile, "git")
|
|
sb.env = []string{exportedMigrateDSN}
|
|
checkStaleEnvRefused(t, sb, envFile, "git")
|
|
})
|
|
}
|
|
|
|
func checkStaleEnvRefused(t *testing.T, sb scriptBox, envFile, ran string) {
|
|
t.Helper()
|
|
r := sb.run(t)
|
|
if r.code != 1 {
|
|
t.Errorf("exit status %d, want 1 (stderr %q)", r.code, r.stderr)
|
|
}
|
|
if want := staleEnvRefusal(envFile); r.stderr != want {
|
|
t.Errorf("stderr %q, want %q", r.stderr, want)
|
|
}
|
|
if r.stdout != "" {
|
|
t.Errorf("stdout %q, want nothing", r.stdout)
|
|
}
|
|
if r.ran != ran {
|
|
t.Errorf("the script ran %q, want %q", r.ran, ran)
|
|
}
|
|
}
|