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
193 lines
5.7 KiB
Go
193 lines
5.7 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package test
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestScriptsRefuseUnknownArguments runs a copy of each script from a
|
|
// directory with no .env beside it, so the copy reaches no database or
|
|
// container whatever it is given: an argument it accepts stops at the missing
|
|
// .env (or, for teardown-stack.sh and bootstrap-stack.sh, at its first git
|
|
// call), and one it refuses stops before that. The commands that reach a database, the session store or
|
|
// the container engine are shims that record their name and fail, so a script
|
|
// that ran on past both checks would show here instead of touching the stack.
|
|
func TestScriptsRefuseUnknownArguments(t *testing.T) {
|
|
for _, s := range []struct {
|
|
script string
|
|
usage string
|
|
accept []argRow
|
|
refuse []argRow
|
|
}{
|
|
{
|
|
script: "reset-app-db.sh",
|
|
usage: "usage: cd test && ./reset-app-db.sh [--rebuild]\n",
|
|
accept: []argRow{
|
|
{"no argument", nil, 1, "error: %s/.env not found. Run ./bootstrap-stack.sh first.\n", ""},
|
|
{"rebuild", []string{"--rebuild"}, 1, "error: %s/.env not found. Run ./bootstrap-stack.sh first.\n", ""},
|
|
},
|
|
refuse: []argRow{
|
|
{"help", []string{"--help"}, 2, "", ""},
|
|
{"short help", []string{"-h"}, 2, "", ""},
|
|
{"a word", []string{"rebuild"}, 2, "", ""},
|
|
{"an empty argument", []string{""}, 2, "", ""},
|
|
{"rebuild and another", []string{"--rebuild", "--help"}, 2, "", ""},
|
|
},
|
|
},
|
|
{
|
|
script: "reset-test-db.sh",
|
|
usage: "usage: cd test && ./reset-test-db.sh\n",
|
|
accept: []argRow{
|
|
{"no argument", nil, 1, "error: %s/.env not found. Run ./bootstrap-stack.sh first.\n", ""},
|
|
},
|
|
refuse: []argRow{
|
|
{"help", []string{"--help"}, 2, "", ""},
|
|
{"an empty argument", []string{""}, 2, "", ""},
|
|
{"two arguments", []string{"a", "b"}, 2, "", ""},
|
|
},
|
|
},
|
|
{
|
|
script: "teardown-stack.sh",
|
|
usage: "usage: cd test && ./teardown-stack.sh\n",
|
|
accept: []argRow{
|
|
{"no argument", nil, 1, "", "git"},
|
|
},
|
|
refuse: []argRow{
|
|
{"help", []string{"--help"}, 2, "", ""},
|
|
{"an empty argument", []string{""}, 2, "", ""},
|
|
{"two arguments", []string{"a", "b"}, 2, "", ""},
|
|
},
|
|
},
|
|
{
|
|
script: "bootstrap-stack.sh",
|
|
usage: "usage: cd test && ./bootstrap-stack.sh [slug]\n",
|
|
accept: []argRow{
|
|
{"no argument", nil, 1, "", "git"},
|
|
{"a slug", []string{"mc-review_2"}, 1, "", "git"},
|
|
},
|
|
refuse: []argRow{
|
|
{"help", []string{"--help"}, 2, "", ""},
|
|
{"short help", []string{"-h"}, 2, "", ""},
|
|
{"an empty argument", []string{""}, 2, "", ""},
|
|
{"an uppercase slug", []string{"My-slug"}, 2, "", ""},
|
|
{"a slug led by an underscore", []string{"_slug"}, 2, "", ""},
|
|
{"two arguments", []string{"a", "b"}, 2, "", ""},
|
|
},
|
|
},
|
|
} {
|
|
t.Run(s.script, func(t *testing.T) {
|
|
sb := newScriptBox(t, s.script)
|
|
rows := s.accept
|
|
for _, r := range s.refuse {
|
|
r.stderr = s.usage
|
|
rows = append(rows, r)
|
|
}
|
|
for _, c := range rows {
|
|
t.Run(c.name, func(t *testing.T) { c.check(t, sb) })
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// argRow is one invocation of a script and what it must do.
|
|
type argRow struct {
|
|
name string
|
|
args []string
|
|
code int
|
|
stderr string // %s is the script's directory
|
|
ran string // the shimmed commands the script may call
|
|
}
|
|
|
|
func (c argRow) check(t *testing.T, sb scriptBox) {
|
|
t.Helper()
|
|
want := c.stderr
|
|
if strings.Contains(want, "%s") {
|
|
want = fmt.Sprintf(want, sb.dir)
|
|
}
|
|
r := sb.run(t, c.args...)
|
|
if r.code != c.code {
|
|
t.Errorf("exit status %d, want %d (stderr %q)", r.code, c.code, r.stderr)
|
|
}
|
|
if 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 != c.ran {
|
|
t.Errorf("the script ran %q, want %q", r.ran, c.ran)
|
|
}
|
|
}
|
|
|
|
// scriptBox holds a copy of one test/ script in a directory of its own, with
|
|
// shims in place of every command that reaches the stack.
|
|
type scriptBox struct {
|
|
bash, dir, script, bin, calls string
|
|
env []string // added to the script's environment
|
|
}
|
|
|
|
type scriptRun struct {
|
|
code int
|
|
stdout, stderr string
|
|
ran string // the shimmed commands the script called, in order
|
|
}
|
|
|
|
func newScriptBox(t *testing.T, name string) scriptBox {
|
|
t.Helper()
|
|
src, err := os.ReadFile(name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bash, err := exec.LookPath("bash")
|
|
if err != nil {
|
|
t.Fatalf("bash: %v", err)
|
|
}
|
|
dir := t.TempDir()
|
|
sb := scriptBox{bash: bash, dir: dir, script: filepath.Join(dir, name), bin: filepath.Join(dir, "bin"), calls: filepath.Join(dir, "calls")}
|
|
if err := os.WriteFile(sb.script, src, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(sb.bin, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, shim := range []string{"psql", "pg_dump", "docker", "valkey-cli", "redis-cli", "go", "git"} {
|
|
body := "#!/bin/sh\necho " + shim + " >> \"$SHIM_LOG\"\nexit 1\n"
|
|
if err := os.WriteFile(filepath.Join(sb.bin, shim), []byte(body), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return sb
|
|
}
|
|
|
|
func (sb scriptBox) run(t *testing.T, args ...string) scriptRun {
|
|
t.Helper()
|
|
_ = os.Remove(sb.calls)
|
|
cmd := exec.Command(sb.bash, append([]string{sb.script}, args...)...)
|
|
cmd.Dir = sb.dir
|
|
cmd.Env = append([]string{"PATH=" + sb.bin + ":/usr/bin:/bin", "SHIM_LOG=" + sb.calls}, sb.env...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
|
r := scriptRun{}
|
|
if err := cmd.Run(); err != nil {
|
|
var exit *exec.ExitError
|
|
if !errors.As(err, &exit) {
|
|
t.Fatal(err)
|
|
}
|
|
r.code = exit.ExitCode()
|
|
}
|
|
r.stdout, r.stderr = stdout.String(), stderr.String()
|
|
if got, err := os.ReadFile(sb.calls); err == nil {
|
|
r.ran = strings.Join(strings.Fields(string(got)), ", ")
|
|
}
|
|
return r
|
|
}
|