dbtest gains UpSQL, ApplyVersion, SimpleProtocol, Tx, On and Q (Exec, Scan, ID, Int, String, Strings, Attempt, Refuse), WantPgError, Name and the SQLState constants. internal/db's mustExec, mustScan, mustStrings, deliveryTx, attempt, mustRefuse, assertPgError, the sqlstate constants and deliveryName become their dbtest counterparts at every caller; the fx builders' id scans become Q.ID. The fx builders and the helpers built on them lose their unused ctx parameter. The two tests that open one transaction in the parent (TestProviderConnectionConstraints, TestDeliverySchemaFacts) rebind it in each subtest that runs a statement with dbtest.On(t, q.Tx()). TestTaxonomyMigrationBuckets runs on a scratch database through two connections (simple protocol to apply, extended to assert) instead of a docker container; its checks are tables of (query, want) in three named phases, and its gocognit pin is deleted. TestIntegrationDeliveryMigrationUpDownUp uses Scratch, UpTo and ApplyVersion; TestInvoiceIssuedPaymentPaidBackfill replays 00022 through UpSQL on a simple-protocol transaction; the reader tests in internal/migrate use Tx; TestFullMigrationSequence and the delivery benchmark migrate a scratch database with migrate.Sources(). The delivery benchmark's skip text is unified with the openers'. No test is added or renamed. 13 breaks, each failed the old and the new test: the 11 on the migration subjects, one on a WantPgError caller and one on a Q.Refuse caller.
277 lines
10 KiB
Go
277 lines
10 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package migrate_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
|
)
|
|
|
|
var update = flag.Bool("update", false, "write testdata/console-reader-columns.txt from the database")
|
|
|
|
const (
|
|
markRead = "read"
|
|
markWithhold = "withhold"
|
|
)
|
|
|
|
// columnsFile is the golden file, relative to this package; columnsFileName
|
|
// is the same path from the repository root, for messages.
|
|
var (
|
|
columnsFile = filepath.Join("testdata", "console-reader-columns.txt")
|
|
columnsFileName = "internal/migrate/testdata/console-reader-columns.txt"
|
|
)
|
|
|
|
const columnsHeader = `# Whether console_reader can SELECT each column of every table, view and
|
|
# materialized view in the console's schemas after scripts/db-reader-role.sql
|
|
# runs. Partitions are read through their parent and are not listed.
|
|
# TestConsoleReaderColumns in internal/migrate compares this file with a
|
|
# migrated database; its -update flag rewrites it.
|
|
`
|
|
|
|
type readerColumn struct {
|
|
schema, table, column string
|
|
readable bool
|
|
}
|
|
|
|
func (c readerColumn) name() string { return c.schema + "." + c.table + "." + c.column }
|
|
|
|
func (c readerColumn) mark() string {
|
|
if c.readable {
|
|
return markRead
|
|
}
|
|
return markWithhold
|
|
}
|
|
|
|
// TestConsoleReaderColumns checks that every column in the console's
|
|
// schemas is classified for console_reader, the login role
|
|
// scripts/db-reader-role.sql creates (docs/database-management.md, "The
|
|
// console_reader role"). The test runs the script inside a transaction it
|
|
// rolls back, reads the role's SELECT privilege on every column of every
|
|
// table, view and materialized view, and compares the result with
|
|
// testdata/console-reader-columns.txt, which marks each column "read" or
|
|
// "withhold". Partitions are left out: the role reads them through their
|
|
// parent. A column the file does not list fails the test, so a migration
|
|
// cannot add a column without a reviewed mark, and a pattern removed from
|
|
// the script's personal list fails it through the marks it contradicts.
|
|
//
|
|
// The test cannot catch code that starts writing personal data into an
|
|
// existing column: the column keeps its "read" mark and the role still
|
|
// reads it. The change that adds such a writer extends the personal list.
|
|
//
|
|
// With the test stack's TEST_DATABASE_URL set,
|
|
//
|
|
// go test ./internal/migrate/ -run TestConsoleReaderColumns -count=1 -update
|
|
//
|
|
// writes the current state to the file instead of comparing; the diff is
|
|
// the review.
|
|
//
|
|
// CREATE ROLE is transactional, so the rollback removes the role the run
|
|
// created, although roles belong to the whole cluster; the test checks that.
|
|
func TestConsoleReaderColumns(t *testing.T) {
|
|
database := dbtest.Open(t)
|
|
sources := migrate.Sources()
|
|
script, err := os.ReadFile(filepath.Join("..", "..", "scripts", "db-reader-role.sql"))
|
|
if err != nil {
|
|
t.Fatalf("read the script: %v", err)
|
|
}
|
|
var schemas []string
|
|
for _, src := range sources {
|
|
schemas = append(schemas, src.Name)
|
|
}
|
|
slices.Sort(schemas)
|
|
|
|
ctx := context.Background()
|
|
existed := consoleReaderExists(t, ctx, database)
|
|
got := runReaderScript(t, ctx, database, string(script), schemas)
|
|
if exists := consoleReaderExists(t, ctx, database); exists != existed {
|
|
t.Errorf("console_reader exists = %v after the rollback, %v before it", exists, existed)
|
|
}
|
|
|
|
if *update {
|
|
var b strings.Builder
|
|
b.WriteString(columnsHeader)
|
|
for _, c := range got {
|
|
fmt.Fprintf(&b, "%-8s %s\n", c.mark(), c.name())
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(columnsFile), 0o755); err != nil {
|
|
t.Fatalf("create %s: %v", filepath.Dir(columnsFileName), err)
|
|
}
|
|
if err := os.WriteFile(columnsFile, []byte(b.String()), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", columnsFileName, err)
|
|
}
|
|
t.Logf("wrote %d columns to %s", len(got), columnsFileName)
|
|
return
|
|
}
|
|
|
|
want := readColumnsFile(t)
|
|
var problems []string
|
|
seen := make(map[string]bool, len(got))
|
|
for _, c := range got {
|
|
seen[c.name()] = true
|
|
mark, ok := want[c.name()]
|
|
switch {
|
|
case !ok:
|
|
problems = append(problems, fmt.Sprintf("classify %s in %s (console_reader %s it now)",
|
|
c.name(), columnsFileName, readsOrNot(c.readable)))
|
|
case mark != c.mark():
|
|
problems = append(problems, fmt.Sprintf("%s is marked %s, but console_reader %s it",
|
|
c.name(), mark, readsOrNot(c.readable)))
|
|
}
|
|
}
|
|
for name := range want {
|
|
if !seen[name] {
|
|
problems = append(problems, fmt.Sprintf("%s is listed but no longer exists", name))
|
|
}
|
|
}
|
|
if len(problems) > 0 {
|
|
slices.Sort(problems)
|
|
t.Errorf("console_reader's columns disagree with %s:\n %s\n"+
|
|
"A column that holds personal data goes in the personal list in scripts/db-reader-role.sql.\n"+
|
|
"Then write the current state to the file and review the diff, with TEST_DATABASE_URL from test/.env:\n"+
|
|
" go test ./internal/migrate/ -run TestConsoleReaderColumns -count=1 -update",
|
|
columnsFileName, strings.Join(problems, "\n "))
|
|
}
|
|
}
|
|
|
|
func readsOrNot(readable bool) string {
|
|
if readable {
|
|
return "reads"
|
|
}
|
|
return "does not read"
|
|
}
|
|
|
|
func consoleReaderExists(t *testing.T, ctx context.Context, database *sql.DB) bool {
|
|
t.Helper()
|
|
var exists bool
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'console_reader')`).Scan(&exists); err != nil {
|
|
t.Fatalf("look up console_reader: %v", err)
|
|
}
|
|
return exists
|
|
}
|
|
|
|
// runReaderScript runs the script in a transaction it rolls back and
|
|
// returns every column of the schemas' tables, views and materialized
|
|
// views with the role's SELECT privilege on it, sorted by schema, table and
|
|
// column. It also fails the test when, after the run, the role is a member
|
|
// of a role, could execute a SECURITY DEFINER function, or holds USAGE on a
|
|
// different set of schemas or any grant outside them.
|
|
func runReaderScript(t *testing.T, ctx context.Context, database *sql.DB, script string, schemas []string) []readerColumn {
|
|
t.Helper()
|
|
tx := dbtest.Tx(t, database).Tx()
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if _, err := tx.ExecContext(ctx, script); err != nil {
|
|
t.Fatalf("run scripts/db-reader-role.sql: %v", err)
|
|
}
|
|
|
|
var got []readerColumn
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT n.nspname, c.relname, a.attname,
|
|
has_column_privilege('console_reader', c.oid, a.attnum, 'SELECT')
|
|
FROM pg_class c
|
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
WHERE n.nspname = ANY ($1)
|
|
AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
|
|
AND NOT c.relispartition
|
|
ORDER BY n.nspname, c.relname, a.attname`, schemas)
|
|
if err != nil {
|
|
t.Fatalf("read column privileges: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var c readerColumn
|
|
if err := rows.Scan(&c.schema, &c.table, &c.column, &c.readable); err != nil {
|
|
t.Fatalf("scan column: %v", err)
|
|
}
|
|
got = append(got, c)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("read column privileges: %v", err)
|
|
}
|
|
|
|
// has_column_privilege ignores schema USAGE, and the script finds the
|
|
// schemas it grants in by their ledgers, not through migrate.Sources():
|
|
// the role must hold USAGE on exactly the sources' schemas and no grant
|
|
// on anything outside them.
|
|
var memberOf, definers, usage, outside string
|
|
if err := tx.QueryRowContext(ctx, `
|
|
SELECT coalesce((SELECT string_agg(m.roleid::regrole::text, ', ')
|
|
FROM pg_auth_members m WHERE m.member = 'console_reader'::regrole), ''),
|
|
coalesce((SELECT string_agg(p.oid::regprocedure::text, ', ')
|
|
FROM pg_proc p
|
|
WHERE p.prosecdef AND has_function_privilege('console_reader', p.oid, 'EXECUTE')), ''),
|
|
coalesce((SELECT string_agg(n.nspname, ' ' ORDER BY n.nspname)
|
|
FROM pg_namespace n, aclexplode(n.nspacl) a
|
|
WHERE a.grantee = 'console_reader'::regrole AND a.privilege_type = 'USAGE'), ''),
|
|
coalesce((SELECT string_agg(DISTINCT pg_describe_object(d.classid, d.objid, 0), ', ')
|
|
FROM pg_shdepend d
|
|
LEFT JOIN pg_class c ON d.classid = 'pg_class'::regclass AND c.oid = d.objid
|
|
LEFT JOIN pg_namespace n
|
|
ON n.oid = coalesce(c.relnamespace,
|
|
CASE WHEN d.classid = 'pg_namespace'::regclass THEN d.objid END)
|
|
WHERE d.refclassid = 'pg_authid'::regclass
|
|
AND d.refobjid = 'console_reader'::regrole AND d.deptype = 'a'
|
|
AND d.dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
|
|
AND (n.nspname IS NULL OR n.nspname <> ALL ($1))), '')`, schemas,
|
|
).Scan(&memberOf, &definers, &usage, &outside); err != nil {
|
|
t.Fatalf("read console_reader's memberships, functions and schemas: %v", err)
|
|
}
|
|
if memberOf != "" {
|
|
t.Errorf("console_reader is a member of %s", memberOf)
|
|
}
|
|
if definers != "" {
|
|
t.Errorf("console_reader can execute SECURITY DEFINER functions: %s", definers)
|
|
}
|
|
if usage != strings.Join(schemas, " ") {
|
|
t.Errorf("console_reader holds USAGE on schemas [%s], the migration sources are [%s]",
|
|
usage, strings.Join(schemas, " "))
|
|
}
|
|
if outside != "" {
|
|
t.Errorf("console_reader holds privileges outside the console's schemas: %s", outside)
|
|
}
|
|
|
|
if err := tx.Rollback(); err != nil {
|
|
t.Fatalf("roll back: %v", err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// readColumnsFile parses the golden file into name -> mark. Blank lines and
|
|
// lines starting with # are skipped; any other line is a mark and a name.
|
|
func readColumnsFile(t *testing.T) map[string]string {
|
|
t.Helper()
|
|
data, err := os.ReadFile(columnsFile)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v (write it with -update)", columnsFileName, err)
|
|
}
|
|
want := make(map[string]string)
|
|
for i, line := range strings.Split(string(data), "\n") {
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) != 2 || (fields[0] != markRead && fields[0] != markWithhold) {
|
|
t.Fatalf("%s:%d: want %q or %q and a schema.table.column, got %q",
|
|
columnsFileName, i+1, markRead, markWithhold, line)
|
|
}
|
|
if _, dup := want[fields[1]]; dup {
|
|
t.Fatalf("%s:%d: %s is listed twice", columnsFileName, i+1, fields[1])
|
|
}
|
|
want[fields[1]] = fields[0]
|
|
}
|
|
return want
|
|
}
|