Files
member-console/internal/migrate/console_reader_test.go
T
cgalo5758 11e0f6ee86 Run the migration tests on scratch databases
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.
2026-10-07 16:22:18 -05:00

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
}