Fifth section of #23: the start-up check. Before any migration runs, on both connections, the console refuses when the two strings log in as one user or reach different databases, when the runtime user is a superuser or a member of a superuser role, when it owns or is a member of the owner of the database, when the database is owned by neither the migration user nor a superuser, when the runtime user can create objects in the database, in public or in a console schema, or when it owns an object in those schemas that a name lookup could resolve (relations, functions, operators, types, and operator classes, families and extensions database-wide). After the migrations and the boot grant, on the runtime connection, it refuses when console_runtime is missing, when the runtime user does not inherit it or holds it with SET, when the schema is behind the code, when the runtime user reaches an owner role, owns an object, holds any direct membership but console_runtime, or holds a privilege an enclosure withholds. Each refusal names the statement that fixes it, with names quoted by the database; a check query that fails refuses too. migrate up, down and status refuse a database whose owner is neither user before goose runs. The boot grant is now WITH INHERIT TRUE, SET FALSE: the runtime user needs only to inherit console_runtime, and without SET on that first membership no SET ROLE reaches a role through it. Granting again updates an existing membership in place; this worktree's runtime user held SET from the previous section and lost it at the suite's boot. Every check runs in a read-only REPEATABLE READ transaction that begins with SET LOCAL search_path = pg_catalog, pg_temp, and names every function and catalog relation with pg_catalog., so nothing the runtime user planted (a function such as public.quote_ident(name), or an operator such as public.=(oid, integer), which qualified function names alone did not exclude) runs in the migration user's session or writes a statement the console prints. TestCheckQueriesQualifyEveryName scans the query constants and fails on an unqualified function call or catalog relation. Full suite on reset databases: 4370 pass, 15 skip, none failed. The console booted on the test stack and logged "runtime database user checked" with the runtime and migration users. make lint passes. Break runs that disable an early refusal or a test's cleanup can leave a role or a membership in the shared test cluster; those were removed after the run. Deliberate breaks, scripts/mutation-check -side new, all CAUGHT (76): checks 3a to 3c run before checks 1 to 3 checks 1 to 4 run after the migrations the boot grant runs before the superuser check the database-ownership check removed (TestCheckBeforeMigrating) the database-ownership check removed (TestOpenRuntimeDatabase) check 4 tests datdba = current_user in place of MEMBER check 4 reads the first database in pg_database check 4 without its datname = current_database() filter the migration identity read outside the pinned read-only transaction, with an unqualified quote_ident the runtime identity read outside the pinned read-only transaction, with an unqualified quote_ident the migration pool opened without role=none (owner set its own role) check 12 reads has_schema_privilege in place of the ACL entries check 12's schema CREATE query removed (TestCheckBeforeMigrating) check 12's schema CREATE query removed (TestOpenRuntimeDatabase) check 12's ownership query removed (TestCheckBeforeMigrating) check 12's ownership query removed (TestOpenRuntimeDatabase) check 12 runs after the migrations the boot grant's pg_catalog.format written format check 12's queries read outside the pinned read-only transaction (TestCheckBeforeMigrating) check 12's queries read outside the pinned read-only transaction (TestOpenRuntimeDatabase) CheckRuntimeUser's transaction without the search-path pin the second query of checks 9 and 12 casts the login with regrole CheckMigrationDatabaseOwner removed from dbMigration dbMigration checks before up only CheckMigrationDatabaseOwner removed from dbRuntime the owner half refuses a superuser-owned database copy 3d prints the database name unquoted check 11 without the four-privilege read copy 9 prints the user in place of the grantee check 11's grantee filter dropped check 11's predefined-role query removed copy 3c prints the direct role unquoted copy 6 prints the direct role unquoted check 6 tests MEMBER in place of USAGE check 8 tests USAGE in place of MEMBER check 9 without functions check 9 without public check 9 tests owner = current_user in place of MEMBER the direct-membership check removed (TestCheckRuntimeUser) the direct-membership check removed (TestOpenRuntimeDatabase) the direct-membership check counts console_runtime the direct-membership check runs before check 8 check 11 removed check 3 without its membership half copy 5 quotes the name in Go (%q) an exemption when env is development a failed check query passes the scratch database is dropped after the login that owns it (test-side) an unqualified quote_ident( in the identity query an unqualified pg_roles in the owner check PendingMigrations calls to_regclass without pg_catalog. the query constants renamed so none begins query (test-side) the search-path pin removed from the pinned read-only transaction the search-path pin removed from the savepoint a test runs a check in check 5 removed check 6 does not read the SET option check 6 does not read the SET option (separate-step start) the boot grant leaves the SET option (TestGrantRuntimeRole) the boot grant leaves the SET option (TestOpenRuntimeDatabase) the database CREATE query removed (TestCheckBeforeMigrating) the database CREATE query removed (TestOpenRuntimeDatabase) the database CREATE query reads has_database_privilege in place of the ACL entries check 12's ownership query reads public only check 12's ownership query reads public only (TestOpenRuntimeDatabase) check 9 without operators check 9 without types check 9 without operator classes check 9 without operator families check 9 without extensions start's refusal log lacks the owner refusal the scan folds no case (test-side) the scan reads a quoted call as no call (test-side) the scan reads no line comment (test-side) the scan skips a concatenated constant (test-side) the scan passes a query constant without a value (test-side) copy 5a prints only the GRANT, not the REVOKE by the grantor
680 lines
26 KiB
Go
680 lines
26 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package cmd
|
|
|
|
// Tests of the runtime database mode (openspec change runtime-database-user,
|
|
// design D1, D8 and D11, task 4.8). Every row works on a scratch database and
|
|
// on login roles the test creates with a password and drops in its cleanup:
|
|
// the migration user is the superuser TEST_DATABASE_URL names, the runtime
|
|
// user is a login that owns nothing.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/viper"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
|
)
|
|
|
|
const runtimeLoginPassword = "runtime-login-password"
|
|
|
|
var migrationFileVersion = regexp.MustCompile(`^(\d+)_`)
|
|
|
|
// highestVersions maps each stream to the highest version it embeds.
|
|
func highestVersions(t *testing.T) map[string]int64 {
|
|
t.Helper()
|
|
out := map[string]int64{}
|
|
for _, src := range migrate.Sources() {
|
|
entries, err := fs.ReadDir(src.Migrations, src.Dir)
|
|
if err != nil {
|
|
t.Fatalf("read %s migrations: %v", src.Name, err)
|
|
}
|
|
for _, e := range entries {
|
|
if m := migrationFileVersion.FindStringSubmatch(e.Name()); m != nil {
|
|
v, err := strconv.ParseInt(m[1], 10, 64)
|
|
if err != nil {
|
|
t.Fatalf("migration %s: %v", e.Name(), err)
|
|
}
|
|
out[src.Name] = max(out[src.Name], v)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bootBox is one scratch database with its two logins' addresses.
|
|
type bootBox struct {
|
|
t *testing.T
|
|
scratch string // the migration user's address: the superuser
|
|
name string // the scratch database's name
|
|
login string // the runtime login
|
|
runtime string // the runtime login's address
|
|
admin *sql.DB
|
|
}
|
|
|
|
// newBootBox creates a scratch database and a runtime login that owns nothing.
|
|
func newBootBox(t *testing.T) *bootBox {
|
|
t.Helper()
|
|
scratch := dbtest.Scratch(t)
|
|
u, err := url.Parse(scratch)
|
|
if err != nil {
|
|
t.Fatalf("parse the scratch address: %v", err)
|
|
}
|
|
box := &bootBox{t: t, scratch: scratch, name: strings.TrimPrefix(u.Path, "/"), login: dbtest.Name("mc_rt_"),
|
|
admin: dbtest.Connect(t, dbtest.DSN(t))}
|
|
dbtest.On(t, box.admin).Exec(fmt.Sprintf("CREATE ROLE %s LOGIN PASSWORD '%s'", box.login, runtimeLoginPassword))
|
|
t.Cleanup(func() {
|
|
box.dropDatabase()
|
|
if _, err := box.admin.ExecContext(context.Background(), "DROP ROLE IF EXISTS "+box.login); err != nil {
|
|
t.Errorf("drop role %s: %v", box.login, err)
|
|
}
|
|
})
|
|
u.User = url.UserPassword(box.login, runtimeLoginPassword)
|
|
box.runtime = u.String()
|
|
return box
|
|
}
|
|
|
|
// configure points the settings at the box: db-dsn always, db-migrate-dsn when
|
|
// withMigration is true.
|
|
func (b *bootBox) configure(withMigration bool) {
|
|
b.t.Helper()
|
|
viper.Reset()
|
|
b.t.Cleanup(viper.Reset)
|
|
viper.Set("env", "development")
|
|
viper.Set("db-dsn", b.runtime)
|
|
if withMigration {
|
|
viper.Set("db-migrate-dsn", b.scratch)
|
|
}
|
|
}
|
|
|
|
// migrateTo migrates the scratch database as the migration user, every stream
|
|
// to its highest version except stripe, which stops one version short when
|
|
// short is true.
|
|
func (b *bootBox) migrateTo(short bool) {
|
|
b.t.Helper()
|
|
scratch := dbtest.Connect(b.t, b.scratch)
|
|
highest := highestVersions(b.t)
|
|
for _, src := range migrate.Sources() {
|
|
version := highest[src.Name]
|
|
if short && src.Name == "stripe" {
|
|
version--
|
|
}
|
|
dbtest.UpTo(b.t, scratch, src, version)
|
|
}
|
|
dbtest.On(b.t, scratch).Exec(fmt.Sprintf("GRANT %s TO %s WITH INHERIT TRUE, SET FALSE", db.RuntimeGroupRole, b.login))
|
|
}
|
|
|
|
// open opens the database in runtime mode and returns the pool and the log.
|
|
func (b *bootBox) open() (*sql.DB, *bytes.Buffer, error) {
|
|
b.t.Helper()
|
|
var logs bytes.Buffer
|
|
logger := slog.New(slog.NewTextHandler(&logs, nil))
|
|
database, closeDB, err := openDB(context.Background(), logger, dbRuntime)
|
|
if err != nil {
|
|
return nil, &logs, err
|
|
}
|
|
b.t.Cleanup(closeDB)
|
|
return database, &logs, nil
|
|
}
|
|
|
|
// ledgers lists, per stream, the highest applied version and the number of
|
|
// applied rows, read as the migration user.
|
|
func (b *bootBox) ledgers() string {
|
|
b.t.Helper()
|
|
scratch := dbtest.Connect(b.t, b.scratch)
|
|
var parts []string
|
|
for _, src := range migrate.Sources() {
|
|
var top, n sql.NullInt64
|
|
query := "SELECT max(version_id), count(*) FROM public.goose_db_version_" + src.Name + " WHERE is_applied"
|
|
if err := scratch.QueryRow(query).Scan(&top, &n); err != nil {
|
|
parts = append(parts, src.Name+": "+err.Error())
|
|
continue
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%s:%d:%d", src.Name, top.Int64, n.Int64))
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// migrationSessions counts the sessions of the migration user on the scratch
|
|
// database, waiting briefly for a closed pool's sessions to end.
|
|
func (b *bootBox) migrationSessions() int64 {
|
|
b.t.Helper()
|
|
q := dbtest.On(b.t, b.admin)
|
|
var n int64
|
|
for range 30 {
|
|
n = q.Int(`SELECT count(*) FROM pg_stat_activity
|
|
WHERE datname = $1 AND usename = (SELECT current_user::text)`, b.name)
|
|
if n == 0 {
|
|
return 0
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// scratchAdmin opens the scratch database as the migration user, which is the
|
|
// superuser the test stack's TEST_DATABASE_URL names.
|
|
func (b *bootBox) scratchAdmin() *dbtest.Q {
|
|
b.t.Helper()
|
|
return dbtest.On(b.t, dbtest.Connect(b.t, b.scratch))
|
|
}
|
|
|
|
// ownedBy gives the scratch database to role.
|
|
func (b *bootBox) ownedBy(role string) {
|
|
b.t.Helper()
|
|
dbtest.On(b.t, b.admin).Exec(fmt.Sprintf("ALTER DATABASE %s OWNER TO %s", b.name, role))
|
|
}
|
|
|
|
// dropDatabase drops the scratch database ahead of the roles that own it or
|
|
// hold privileges in it. Go runs t.Cleanup functions in reverse order and
|
|
// dbtest.Scratch registers its drop first, while DROP ROLE fails as long as
|
|
// the role owns the database or an object in it, so each cleanup that drops a
|
|
// role calls this first.
|
|
func (b *bootBox) dropDatabase() {
|
|
if _, err := b.admin.ExecContext(context.Background(), fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", b.name)); err != nil {
|
|
b.t.Errorf("drop database %s: %v", b.name, err)
|
|
}
|
|
}
|
|
|
|
// thirdRole creates a login-less role, neither the migration user nor the
|
|
// runtime login, which the test drops.
|
|
func (b *bootBox) thirdRole() string {
|
|
b.t.Helper()
|
|
role := dbtest.Name("mc_third_")
|
|
dbtest.On(b.t, b.admin).Exec("CREATE ROLE " + role)
|
|
b.t.Cleanup(func() {
|
|
b.dropDatabase()
|
|
if _, err := b.admin.ExecContext(context.Background(), "DROP ROLE IF EXISTS "+role); err != nil {
|
|
b.t.Errorf("drop role %s: %v", role, err)
|
|
}
|
|
})
|
|
return role
|
|
}
|
|
|
|
// plant runs a statement as the runtime login, which creates what the test
|
|
// plants.
|
|
func (b *bootBox) plant(statement string) {
|
|
b.t.Helper()
|
|
dbtest.On(b.t, dbtest.Connect(b.t, b.runtime)).Exec(statement)
|
|
}
|
|
|
|
// wantRefusal opens the database in runtime mode and asserts the refusal.
|
|
func (b *bootBox) wantRefusal(want string) {
|
|
b.t.Helper()
|
|
_, _, err := b.open()
|
|
var refusal *db.RuntimeUserRefusal
|
|
if !errors.As(err, &refusal) || err.Error() != want {
|
|
b.t.Errorf("openDB error = %v, want the refusal %q", err, want)
|
|
}
|
|
}
|
|
|
|
// wantLedgers asserts that the ledgers read as before.
|
|
func (b *bootBox) wantLedgers(before string) {
|
|
b.t.Helper()
|
|
if after := b.ledgers(); after != before {
|
|
b.t.Errorf("the ledgers changed: %s, want %s", after, before)
|
|
}
|
|
}
|
|
|
|
// wantNoMembership asserts that the runtime login holds no membership in the
|
|
// group role.
|
|
func (b *bootBox) wantNoMembership() {
|
|
b.t.Helper()
|
|
n := dbtest.On(b.t, b.admin).Int(`SELECT count(*) FROM pg_auth_members m
|
|
JOIN pg_roles g ON g.oid = m.roleid JOIN pg_roles u ON u.oid = m.member
|
|
WHERE g.rolname = $1 AND u.rolname = $2`, db.RuntimeGroupRole, b.login)
|
|
if n != 0 {
|
|
b.t.Errorf("the runtime login %q was granted %s, want no membership", b.login, db.RuntimeGroupRole)
|
|
}
|
|
}
|
|
|
|
// plantedBody is the body of every function the rows plant. A planted function
|
|
// that writes a marker row is blind in a read-only transaction (the write fails)
|
|
// and in a savepoint that rolls back (the row goes), so it raises instead: an
|
|
// exception shows in every path, turns the check into copy 11 and puts this text
|
|
// in the sentence the row compares.
|
|
const plantedBody = `BEGIN RAISE EXCEPTION 'planted function ran as % (superuser %)', current_user, current_setting('is_superuser'); END`
|
|
|
|
// grantWithSet makes the box's login a member of the group role as the boot
|
|
// grant does not leave it: with SET, which lets the login SET ROLE to the group
|
|
// role and to any role the group role holds with SET.
|
|
func (b *bootBox) grantWithSet() {
|
|
b.t.Helper()
|
|
b.scratchAdmin().Exec(fmt.Sprintf("GRANT %s TO %s WITH INHERIT TRUE, SET TRUE", db.RuntimeGroupRole, b.login))
|
|
}
|
|
|
|
// setRole opens a connection as the runtime login and runs SET ROLE to role on
|
|
// it, the way a session of the login would; the membership's SET option is
|
|
// checked against the session user, which a SET LOCAL ROLE inside the test's
|
|
// superuser session would not exercise.
|
|
func (b *bootBox) setRole(role string) error {
|
|
b.t.Helper()
|
|
_, err := dbtest.Connect(b.t, b.runtime).ExecContext(context.Background(), "SET ROLE "+role)
|
|
return err
|
|
}
|
|
|
|
// createInPublic is the sentence of copy 3e for a login that holds CREATE on
|
|
// public through a grant to itself.
|
|
func (b *bootBox) createInPublic() string {
|
|
return fmt.Sprintf("db-dsn logs in as %s, which can create objects in schema public through a grant to %s; "+
|
|
"as the migration user, run: REVOKE CREATE ON SCHEMA public FROM %s;", b.login, b.login, b.login)
|
|
}
|
|
|
|
// openRuntimeDatabaseRows are the rows of TestOpenRuntimeDatabase, each run
|
|
// by its own named function.
|
|
var openRuntimeDatabaseRows = []struct {
|
|
name string
|
|
run func(t *testing.T)
|
|
}{
|
|
{"both strings on a fresh database", bootWithBothStrings},
|
|
{"without db-migrate-dsn on a database behind", bootBehindWithoutMigrationString},
|
|
{"without db-migrate-dsn on a current database", bootCurrentWithoutMigrationString},
|
|
{"migrate up without db-migrate-dsn", migrateUpWithoutMigrationString},
|
|
{"a runtime login that set its own role", bootWithSelfSetRole},
|
|
{"one user for both strings", bootWithOneUser},
|
|
{"a superuser runtime login", bootWithSuperuser},
|
|
{"a runtime login that owns the database", bootWithDatabaseOwner},
|
|
{"a database owner that planted quote_ident", bootWithPlantedQuoteIdent},
|
|
{"a runtime login that can create in public and planted format", bootWithPlantedFormat},
|
|
{"a runtime login that can create in public and planted an operator", bootWithPlantedOperator},
|
|
{"a runtime login that owns a function in public", bootWithOwnedPublicFunction},
|
|
{"start on a database owned by another role", bootOnAForeignOwner},
|
|
{"migrate commands on a database owned by another role", migrateCommandsRefuseAForeignOwner},
|
|
{"migrate up on a database whose owner set its own role", migrateUpRefusesAnOwnerThatSetItsRole},
|
|
{"a runtime login that holds pg_read_server_files", bootWithPredefinedRole},
|
|
{"two databases", bootWithTwoDatabases},
|
|
{"a runtime login that can create schemas in the database", bootWithDatabaseCreate},
|
|
{"a runtime login that owns a table in core before migrating", bootWithOwnedStreamTable},
|
|
{"without db-migrate-dsn on a membership that can SET ROLE", bootSeparateStepWithSet},
|
|
{"the boot grant closes a SET ROLE chain through the group role", bootClosesASetRoleChain},
|
|
}
|
|
|
|
// TestOpenRuntimeDatabase covers tasks 4.8 and 5.5 (design D1, D2, D8, D9,
|
|
// D11): the runtime mode refuses a configuration that would reopen the bypass
|
|
// before it migrates, migrates and grants on the migration connection, closes
|
|
// it, checks the runtime user, and serves as the runtime login; without
|
|
// db-migrate-dsn it runs nothing and refuses a schema that is behind. Every
|
|
// row runs with env set to development, because nothing reads env.
|
|
func TestOpenRuntimeDatabase(t *testing.T) {
|
|
// viper.Reset drops the environment binding; blanking the variables as
|
|
// well keeps the rows off a database that make test exports.
|
|
t.Setenv("MC_DB_DSN", "")
|
|
t.Setenv("MC_DB_DSN_FILE", "")
|
|
t.Setenv("MC_DB_MIGRATE_DSN", "")
|
|
t.Setenv("MC_DB_MIGRATE_DSN_FILE", "")
|
|
for _, row := range openRuntimeDatabaseRows {
|
|
t.Run(row.name, row.run)
|
|
}
|
|
}
|
|
|
|
func bootWithBothStrings(t *testing.T) {
|
|
box := newBootBox(t)
|
|
box.configure(true)
|
|
database, _, err := box.open()
|
|
if err != nil {
|
|
t.Fatalf("openDB: %v", err)
|
|
}
|
|
q := dbtest.On(t, database)
|
|
if got := q.String("SELECT current_user::text"); got != box.login {
|
|
t.Errorf("SELECT current_user on the returned pool = %q, want the runtime login %q", got, box.login)
|
|
}
|
|
if q.String(fmt.Sprintf("SELECT pg_has_role(current_user, '%s', 'USAGE')::text", db.RuntimeGroupRole)) != "true" {
|
|
t.Errorf("the runtime login %q does not inherit %s", box.login, db.RuntimeGroupRole)
|
|
}
|
|
for stream, want := range highestVersions(t) {
|
|
got := q.Int("SELECT max(version_id) FROM public.goose_db_version_" + stream + " WHERE is_applied")
|
|
if got != want {
|
|
t.Errorf("ledger %s through the returned pool is at %d, want %d", stream, got, want)
|
|
}
|
|
}
|
|
if n := box.migrationSessions(); n != 0 {
|
|
t.Errorf("%d sessions of the migration user remain open on the scratch database, want 0", n)
|
|
}
|
|
}
|
|
|
|
func bootBehindWithoutMigrationString(t *testing.T) {
|
|
box := newBootBox(t)
|
|
box.migrateTo(true)
|
|
before := box.ledgers()
|
|
box.configure(false)
|
|
_, _, err := box.open()
|
|
want := fmt.Sprintf("the schema is behind this release (pending migrations: stripe %d); "+
|
|
"run member-console migrate up with db-migrate-dsn set, then start again", highestVersions(t)["stripe"])
|
|
var refusal *db.RuntimeUserRefusal
|
|
if !errors.As(err, &refusal) || err.Error() != want {
|
|
t.Errorf("openDB error = %v, want the refusal %q", err, want)
|
|
}
|
|
if after := box.ledgers(); after != before {
|
|
t.Errorf("the ledgers changed: %s, want %s", after, before)
|
|
}
|
|
}
|
|
|
|
func bootCurrentWithoutMigrationString(t *testing.T) {
|
|
box := newBootBox(t)
|
|
box.migrateTo(false)
|
|
before := box.ledgers()
|
|
box.configure(false)
|
|
database, logs, err := box.open()
|
|
if err != nil {
|
|
t.Fatalf("openDB: %v", err)
|
|
}
|
|
if got := dbtest.On(t, database).String("SELECT current_user::text"); got != box.login {
|
|
t.Errorf("SELECT current_user on the returned pool = %q, want the runtime login %q", got, box.login)
|
|
}
|
|
if !strings.Contains(logs.String(), "migrations not run: db-migrate-dsn is not set") {
|
|
t.Errorf("the log lacks the separate-step line:\n%s", logs)
|
|
}
|
|
if strings.Contains(logs.String(), "database migrations applied") {
|
|
t.Errorf("migrations ran without db-migrate-dsn:\n%s", logs)
|
|
}
|
|
if after := box.ledgers(); after != before {
|
|
t.Errorf("the ledgers changed: %s, want %s", after, before)
|
|
}
|
|
}
|
|
|
|
func migrateUpWithoutMigrationString(t *testing.T) {
|
|
viper.Reset()
|
|
t.Cleanup(viper.Reset)
|
|
viper.Set("env", "development")
|
|
viper.Set("db-dsn", "postgres://runtime:pw@127.0.0.1:1/x?connect_timeout=1")
|
|
const want = "member-console migrate needs db-migrate-dsn (MC_DB_MIGRATE_DSN or MC_DB_MIGRATE_DSN_FILE)"
|
|
if err := migrateUpCmd.RunE(migrateUpCmd, nil); err == nil || err.Error() != want {
|
|
t.Errorf("migrate up error = %v, want %q", err, want)
|
|
}
|
|
}
|
|
|
|
func bootWithSelfSetRole(t *testing.T) {
|
|
box := newBootBox(t)
|
|
admin := dbtest.On(t, box.admin)
|
|
admin.Exec(fmt.Sprintf("GRANT %s TO %s WITH INHERIT TRUE", db.RuntimeGroupRole, box.login))
|
|
admin.Exec(fmt.Sprintf("ALTER ROLE %s SET role = '%s'", box.login, db.RuntimeGroupRole))
|
|
box.configure(true)
|
|
database, _, err := box.open()
|
|
if err != nil {
|
|
t.Fatalf("openDB: %v", err)
|
|
}
|
|
if got := dbtest.On(t, database).String("SELECT current_user::text"); got != box.login {
|
|
t.Errorf("SELECT current_user on the returned pool = %q, want the login %q, not the role it set", got, box.login)
|
|
}
|
|
}
|
|
|
|
func bootWithOneUser(t *testing.T) {
|
|
box := newBootBox(t)
|
|
before := box.ledgers()
|
|
box.configure(true)
|
|
viper.Set("db-dsn", box.scratch)
|
|
user := dbtest.On(t, box.admin).String("SELECT current_user::text")
|
|
box.wantRefusal(fmt.Sprintf("db-dsn and db-migrate-dsn both log in as %s; "+
|
|
"db-dsn needs a user of its own that is not a superuser and owns nothing", user))
|
|
box.wantLedgers(before)
|
|
}
|
|
|
|
func bootWithSuperuser(t *testing.T) {
|
|
box := newBootBox(t)
|
|
dbtest.On(t, box.admin).Exec("ALTER ROLE " + box.login + " SUPERUSER")
|
|
before := box.ledgers()
|
|
box.configure(true)
|
|
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, a superuser; set db-dsn to a user without SUPERUSER", box.login))
|
|
box.wantLedgers(before)
|
|
box.wantNoMembership()
|
|
}
|
|
|
|
func bootWithDatabaseOwner(t *testing.T) {
|
|
box := newBootBox(t)
|
|
box.ownedBy(box.login)
|
|
before := box.ledgers()
|
|
box.configure(true)
|
|
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, which owns the database %s; "+
|
|
"as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;", box.login, box.name, box.login))
|
|
box.wantLedgers(before)
|
|
box.wantNoMembership()
|
|
}
|
|
|
|
// bootWithPlantedQuoteIdent plants public.quote_ident(name), which beats the
|
|
// catalog's for a name argument whatever the search path, as the database's
|
|
// owner. Neither connection may run it before the refusal: a run raises, and
|
|
// the refusal that would show is copy 11 with the function's text.
|
|
func bootWithPlantedQuoteIdent(t *testing.T) {
|
|
box := newBootBox(t)
|
|
box.ownedBy(box.login)
|
|
box.plant(`CREATE FUNCTION public.quote_ident(name) RETURNS text LANGUAGE plpgsql AS $$ ` + plantedBody + ` $$`)
|
|
before := box.ledgers()
|
|
box.configure(true)
|
|
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, which owns the database %s; "+
|
|
"as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;", box.login, box.name, box.login))
|
|
box.wantLedgers(before)
|
|
}
|
|
|
|
func bootWithPlantedFormat(t *testing.T) {
|
|
box := newBootBox(t)
|
|
box.scratchAdmin().Exec("GRANT CREATE ON SCHEMA public TO " + box.login)
|
|
box.plant(`CREATE FUNCTION public.format(text, text) RETURNS text LANGUAGE plpgsql AS $$ ` + plantedBody + ` $$`)
|
|
before := box.ledgers()
|
|
box.configure(true)
|
|
box.wantRefusal(box.createInPublic())
|
|
box.wantLedgers(before)
|
|
}
|
|
|
|
// bootWithPlantedOperator plants public.=(oid, integer), which check 12's own
|
|
// query would call as the migration user without the search-path pin; a call
|
|
// raises.
|
|
func bootWithPlantedOperator(t *testing.T) {
|
|
box := newBootBox(t)
|
|
box.scratchAdmin().Exec("GRANT CREATE ON SCHEMA public TO " + box.login)
|
|
box.plant(`CREATE FUNCTION public.mc_eq(oid, integer) RETURNS boolean LANGUAGE plpgsql AS $$ ` + plantedBody + ` $$`)
|
|
box.plant("CREATE OPERATOR public.= (LEFTARG = oid, RIGHTARG = integer, FUNCTION = public.mc_eq)")
|
|
before := box.ledgers()
|
|
box.configure(true)
|
|
box.wantRefusal(box.createInPublic())
|
|
box.wantLedgers(before)
|
|
}
|
|
|
|
func bootWithOwnedPublicFunction(t *testing.T) {
|
|
box := newBootBox(t)
|
|
q := box.scratchAdmin()
|
|
q.Exec("CREATE FUNCTION public.mc_f() RETURNS integer LANGUAGE sql AS 'SELECT 1'")
|
|
q.Exec("ALTER FUNCTION public.mc_f() OWNER TO " + box.login)
|
|
before := box.ledgers()
|
|
box.configure(true)
|
|
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, which owns function public.mc_f(); "+
|
|
"as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;", box.login, box.login))
|
|
box.wantLedgers(before)
|
|
}
|
|
|
|
// migrateCommands are the three commands that open the migration pool.
|
|
var migrateCommands = []*cobra.Command{migrateUpCmd, migrateDownCmd, migrateStatusCmd}
|
|
|
|
// ownerRefusal is the sentence of copy 3d for the scratch database.
|
|
func (b *bootBox) ownerRefusal(owner string) string {
|
|
return fmt.Sprintf("database %s is owned by %s, not the migration user; as the migration user, run: ALTER DATABASE %s OWNER TO CURRENT_USER;",
|
|
b.name, owner, b.name)
|
|
}
|
|
|
|
// wantMigrateRefusal runs a migrate command and asserts the refusal of copy 3d
|
|
// and that the ledgers read as before.
|
|
func (b *bootBox) wantMigrateRefusal(c *cobra.Command, want, before string) {
|
|
b.t.Helper()
|
|
err := c.RunE(c, nil)
|
|
var refusal *db.DatabaseOwnerRefusal
|
|
if !errors.As(err, &refusal) || err.Error() != want {
|
|
b.t.Errorf("migrate %s error = %v, want the refusal %q", c.Name(), err, want)
|
|
}
|
|
b.wantLedgers(before)
|
|
}
|
|
|
|
// wantOwnerRefusal opens the database in runtime mode and asserts the refusal
|
|
// of copy 3d.
|
|
func (b *bootBox) wantOwnerRefusal(want string) {
|
|
b.t.Helper()
|
|
_, _, err := b.open()
|
|
var refusal *db.DatabaseOwnerRefusal
|
|
if !errors.As(err, &refusal) || err.Error() != want {
|
|
b.t.Errorf("openDB error = %v, want the refusal %q", err, want)
|
|
}
|
|
}
|
|
|
|
func bootOnAForeignOwner(t *testing.T) {
|
|
box := newBootBox(t)
|
|
owner := box.thirdRole()
|
|
box.ownedBy(owner)
|
|
before := box.ledgers()
|
|
box.configure(true)
|
|
box.wantOwnerRefusal(box.ownerRefusal(owner))
|
|
box.wantLedgers(before)
|
|
box.wantNoMembership()
|
|
}
|
|
|
|
func migrateCommandsRefuseAForeignOwner(t *testing.T) {
|
|
box := newBootBox(t)
|
|
owner := box.thirdRole()
|
|
box.ownedBy(owner)
|
|
before := box.ledgers()
|
|
box.configure(true)
|
|
for _, c := range migrateCommands {
|
|
box.wantMigrateRefusal(c, box.ownerRefusal(owner), before)
|
|
}
|
|
}
|
|
|
|
func migrateUpRefusesAnOwnerThatSetItsRole(t *testing.T) {
|
|
box := newBootBox(t)
|
|
owner := box.thirdRole()
|
|
box.ownedBy(owner)
|
|
dbtest.On(t, box.admin).Exec(fmt.Sprintf("ALTER DATABASE %s SET role = '%s'", box.name, owner))
|
|
before := box.ledgers()
|
|
box.configure(true)
|
|
box.wantMigrateRefusal(migrateUpCmd, box.ownerRefusal(owner), before)
|
|
}
|
|
|
|
func bootWithPredefinedRole(t *testing.T) {
|
|
box := newBootBox(t)
|
|
dbtest.On(t, box.admin).Exec("GRANT pg_read_server_files TO " + box.login)
|
|
box.configure(true)
|
|
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, a direct member of pg_read_server_files; "+
|
|
"as the migration user, run: REVOKE pg_read_server_files FROM %s;", box.login, box.login))
|
|
q := box.scratchAdmin()
|
|
for stream, want := range highestVersions(t) {
|
|
if got := q.Int("SELECT max(version_id) FROM public.goose_db_version_" + stream + " WHERE is_applied"); got != want {
|
|
t.Errorf("ledger %s is at %d, want %d: the check runs after the migrations", stream, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func bootWithTwoDatabases(t *testing.T) {
|
|
box := newBootBox(t)
|
|
second := dbtest.Scratch(t)
|
|
u, err := url.Parse(second)
|
|
if err != nil {
|
|
t.Fatalf("parse the second scratch address: %v", err)
|
|
}
|
|
secondName := strings.TrimPrefix(u.Path, "/")
|
|
before := box.ledgers()
|
|
box.configure(true)
|
|
viper.Set("db-migrate-dsn", second)
|
|
box.wantRefusal(fmt.Sprintf("db-dsn connects to database %s and db-migrate-dsn to database %s; both must name the same database",
|
|
box.name, secondName))
|
|
box.wantLedgers(before)
|
|
}
|
|
|
|
func bootWithDatabaseCreate(t *testing.T) {
|
|
box := newBootBox(t)
|
|
dbtest.On(t, box.admin).Exec(fmt.Sprintf("GRANT CREATE ON DATABASE %s TO %s", box.name, box.login))
|
|
before := box.ledgers()
|
|
box.configure(true)
|
|
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, which can create schemas in database %s through a grant to %s; "+
|
|
"as the migration user, run: REVOKE CREATE ON DATABASE %s FROM %s;", box.login, box.name, box.login, box.name, box.login))
|
|
box.wantLedgers(before)
|
|
box.wantNoMembership()
|
|
}
|
|
|
|
// bootWithOwnedStreamTable shows check 9 reading a stream schema before the
|
|
// migrations: the database has no migration yet, and the login owns a table in
|
|
// a core schema that the first migration would find.
|
|
func bootWithOwnedStreamTable(t *testing.T) {
|
|
box := newBootBox(t)
|
|
q := box.scratchAdmin()
|
|
q.Exec("CREATE SCHEMA core")
|
|
q.Exec("CREATE TABLE core.mc_t (id integer)")
|
|
q.Exec("ALTER TABLE core.mc_t OWNER TO " + box.login)
|
|
before := box.ledgers()
|
|
box.configure(true)
|
|
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, which owns relation core.mc_t; "+
|
|
"as the migration user, run: REASSIGN OWNED BY %s TO CURRENT_USER;", box.login, box.login))
|
|
box.wantLedgers(before)
|
|
box.wantNoMembership()
|
|
}
|
|
|
|
func bootSeparateStepWithSet(t *testing.T) {
|
|
box := newBootBox(t)
|
|
box.migrateTo(false)
|
|
box.grantWithSet()
|
|
box.configure(false)
|
|
grantor := box.scratchAdmin().String("SELECT pg_catalog.quote_ident(pg_catalog.pg_get_userbyid(m.grantor)) " +
|
|
"FROM pg_catalog.pg_auth_members m WHERE m.roleid = 'console_runtime'::pg_catalog.regrole AND m.set_option")
|
|
box.wantRefusal(fmt.Sprintf("db-dsn logs in as %s, which can SET ROLE to %s through a grant by %s; "+
|
|
"as the migration user, run: REVOKE %s FROM %s GRANTED BY %s; GRANT %s TO %s WITH INHERIT TRUE, SET FALSE;",
|
|
box.login, db.RuntimeGroupRole, grantor, db.RuntimeGroupRole, box.login, grantor, db.RuntimeGroupRole, box.login))
|
|
}
|
|
|
|
// bootClosesASetRoleChain is an existing deployment: the login holds the group
|
|
// role with SET, and the group role holds a role that can insert into an
|
|
// enclosed table with INHERIT FALSE, SET TRUE, which no privilege check of the
|
|
// login's counts. The login can SET ROLE to that role before the boot and
|
|
// cannot after it.
|
|
func bootClosesASetRoleChain(t *testing.T) {
|
|
box := newBootBox(t)
|
|
box.migrateTo(false)
|
|
chained := box.thirdRole()
|
|
box.scratchAdmin().Exec("GRANT INSERT ON core.pool_provisions TO " + chained)
|
|
dbtest.On(t, box.admin).Exec(fmt.Sprintf("GRANT %s TO %s WITH INHERIT FALSE, SET TRUE", chained, db.RuntimeGroupRole))
|
|
box.grantWithSet()
|
|
if err := box.setRole(chained); err != nil {
|
|
t.Fatalf("before the boot, SET ROLE %s as the runtime login = %v, want it to work", chained, err)
|
|
}
|
|
box.configure(true)
|
|
if _, _, err := box.open(); err != nil {
|
|
t.Fatalf("openDB: %v", err)
|
|
}
|
|
dbtest.WantPgError(t, box.setRole(chained), dbtest.SQLStatePrivilege, "")
|
|
}
|
|
|
|
// TestLogOpenFailure covers copy 17: start logs a refusal, the owner refusal of
|
|
// copy 3d among them, as "refused to start", and any other error as a failure
|
|
// to initialize.
|
|
func TestLogOpenFailure(t *testing.T) {
|
|
rows := []struct {
|
|
name string
|
|
err error
|
|
want string
|
|
}{
|
|
{"a runtime user refusal", &db.RuntimeUserRefusal{Message: "refusal"}, `msg="refused to start"`},
|
|
{"a database owner refusal", &db.DatabaseOwnerRefusal{Message: "refusal"}, `msg="refused to start"`},
|
|
{"a wrapped database owner refusal", fmt.Errorf("opening: %w", &db.DatabaseOwnerRefusal{Message: "refusal"}), `msg="refused to start"`},
|
|
{"any other error", errors.New("boom"), `msg="failed to initialize database"`},
|
|
}
|
|
for _, row := range rows {
|
|
t.Run(row.name, func(t *testing.T) {
|
|
var logs bytes.Buffer
|
|
logOpenFailure(slog.New(slog.NewTextHandler(&logs, nil)), row.err)
|
|
if !strings.Contains(logs.String(), row.want) {
|
|
t.Errorf("log = %q, want it to contain %s", logs.String(), row.want)
|
|
}
|
|
})
|
|
}
|
|
}
|