diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2291be5..9b5ec2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["dbkit/postgres", "gonsole/auth"] + module: ["dbkit/postgres", "gonsole/auth", "gonsole/auth/postgres"] defaults: run: working-directory: ${{ matrix.module }} @@ -99,7 +99,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire"] + module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/postgres", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire"] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -121,7 +121,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire"] + module: ["dbkit", "dbkit/postgres", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/postgres", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire"] defaults: run: working-directory: ${{ matrix.module }} diff --git a/README.md b/README.md index bd0c155..d6c4ad9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ need and ignore the rest. core commands, settings and compiled plugins. - [`gonsole/auth`](gonsole/auth/) offers the account commands of a program whose accounts live in gouncer's PostgreSQL or SQLite store. +- [`gonsole/auth/postgres`](gonsole/auth/postgres/) runs those account + commands and their records on a program's PostgreSQL database. - [`gonsole/auth/sqlite`](gonsole/auth/sqlite/) runs those account commands and their records on a program's one SQLite handle. - [`gottext`](gottext/) reads, writes and syncs gettext catalogs for diff --git a/gonsole/auth/postgres/.golangci.yml b/gonsole/auth/postgres/.golangci.yml new file mode 100644 index 0000000..ca7571e --- /dev/null +++ b/gonsole/auth/postgres/.golangci.yml @@ -0,0 +1,81 @@ +version: "2" + +linters: + default: standard + enable: + - cyclop + - depguard + - forbidigo + - gocognit + - lll + - misspell + - revive + - unconvert + - unparam + settings: + cyclop: + max-complexity: 10 + gocognit: + min-complexity: 15 + lll: + line-length: 120 + revive: + rules: + - name: blank-imports + disabled: true + - name: exported + depguard: + rules: + auth-purity: + list-mode: strict + files: + - $all + - "!$test" + allow: + - $gostd + - github.com/gopherium/framework/gonsole$ + - github.com/gopherium/framework/gonsole/auth$ + - github.com/gopherium/framework/dbkit/postgres$ + - github.com/gopherium/gouncer/authkit/postgres$ + - github.com/jackc/pgx/v5 + test-purity: + list-mode: strict + files: + - $test + allow: + - $gostd + - github.com/gopherium/framework/gonsole$ + - github.com/gopherium/framework/gonsole/auth$ + - github.com/gopherium/framework/gonsole/auth/postgres$ + - github.com/gopherium/framework/gonsole/auth/recordstest$ + - github.com/gopherium/framework/gonsole/testkit$ + - github.com/gopherium/framework/dbkit$ + - github.com/gopherium/framework/dbkit/postgres$ + - github.com/gopherium/framework/dbkit/postgres/pgtest$ + - github.com/gopherium/gouncer$ + - github.com/gopherium/gouncer/authkit/postgres$ + - github.com/gopherium/gouncer/authkit/postgres/testdb$ + - github.com/jackc/pgx/v5 + - github.com/peterldowns/pgtestdb$ + - github.com/pressly/goose/v3/lock$ + forbidigo: + analyze-types: true + forbid: + - pattern: ^sql\.Open(DB)?$ + pkg: ^database/sql$ + msg: open PostgreSQL through pgxpool.New or pgx's stdlib.OpenDB + exclusions: + rules: + - path: _test\.go + linters: + - cyclop + - gocognit + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/gopherium/framework diff --git a/gonsole/auth/postgres/CHANGELOG.md b/gonsole/auth/postgres/CHANGELOG.md new file mode 100644 index 0000000..a5e3997 --- /dev/null +++ b/gonsole/auth/postgres/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to the `gonsole/auth/postgres` module are documented +in this file. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the +module follows [Semantic Versioning](https://semver.org/). While at +v0.x, minor releases may contain breaking changes. + +Releases of this module are tagged `gonsole/auth/postgres/vX.Y.Z`. + +## [Unreleased] + +### Added + +- `Stores`, the account and record stores of a call on a pool of the database setting, closed after the call, for `auth.Config.Stores`. +- `Migration` and `RecordMigration`, the two schema steps, each under goose's migration lock. +- The records keep the version table of `gonsole/auth`, `gonsole.goose_db_version`. +- `NewRecordStore`, the record store of the `gonsole.records` table, which lists no record for a limit below zero. +- A constraint, lock or serialization failure of the record store or the records step carries dbkit's error class, such as `dbkit.ErrUnique`. +- An address pgx cannot parse fails `Stores` and the records step with one message that holds no part of the address. diff --git a/gonsole/auth/postgres/commands_test.go b/gonsole/auth/postgres/commands_test.go new file mode 100644 index 0000000..3e787af --- /dev/null +++ b/gonsole/auth/postgres/commands_test.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres_test + +import ( + "fmt" + "testing" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/testkit" +) + +// commandLine is one line a scenario runs and what the database and the output hold after it. +type commandLine struct { + // before is a statement run first, empty for none. + before string + // stdin is what the line reads. + stdin string + // args is the line. + args []string + // check is a lookup that answers true after the line. + check string + // prints are the parts the output must name. + prints []string +} + +// runLine runs line through p on db and fails the test unless it exits 0 and leaves db as line says. +func (db testDB) runLine(p gonsole.Program, line commandLine) { + db.t.Helper() + if line.before != "" { + db.run(line.before) + } + + got := testkit.Run(db.t, p, line.stdin, line.args...) + + if got.Code != gonsole.ExitDone { + db.t.Fatalf("%v = %d with %q, want %d", line.args, got.Code, got.Stderr, gonsole.ExitDone) + } + if !db.found(line.check) { + db.t.Errorf("after %v the database fails %q, want it to hold", line.args, line.check) + } + wantPrinted(db.t, fmt.Sprint(line.args), got.Stdout, line.prints...) + db.noSessionLeft() +} + +func TestEveryAccountCommandRunsOnTheModulesStores(t *testing.T) { + t.Parallel() + + db := empty(t) + p := moduleProgram(db) + asAdmin := []string{"-yes", "-as", "admin@example.com"} + + for _, line := range []commandLine{ + {args: []string{"migrate"}, check: "SELECT to_regclass('gonsole.records') IS NOT NULL", + prints: []string{"migrated accounts", "migrated records"}}, + {stdin: demoPassword + "\n", args: []string{"account:create-admin", "-email", "admin@example.com", + "-name", "Maria Perez", "-role", "admin"}, + check: "SELECT EXISTS (SELECT FROM auth.users WHERE email = 'admin@example.com' AND role = 'admin')"}, + {stdin: demoPassword + "\n", args: []string{"account:create-admin", "-email", "author@example.com", + "-name", "Maria Perez", "-role", "author"}, + check: "SELECT EXISTS (SELECT FROM auth.users WHERE email = 'author@example.com' AND role = 'author')"}, + {before: "UPDATE auth.users SET role = '' WHERE email = 'author@example.com'", + args: append([]string{"account:grant-role", "-role", "editor"}, asAdmin...), + check: "SELECT EXISTS (SELECT FROM auth.users WHERE email = 'author@example.com' AND role = 'editor')"}, + {args: append([]string{"account:role", "author@example.com", "author"}, asAdmin...), + check: "SELECT EXISTS (SELECT FROM auth.users WHERE email = 'author@example.com' AND role = 'author')"}, + {args: append([]string{"account:disable", "author@example.com"}, asAdmin...), + check: "SELECT EXISTS (SELECT FROM auth.users WHERE email = 'author@example.com' AND disabled)"}, + {args: append([]string{"account:enable", "author@example.com"}, asAdmin...), + check: "SELECT EXISTS (SELECT FROM auth.users WHERE email = 'author@example.com' AND NOT disabled)"}, + {args: []string{"account:list"}, check: "SELECT count(*) = 2 FROM auth.users", + prints: []string{"admin@example.com", "author@example.com"}}, + {args: []string{"account:records"}, check: "SELECT count(*) = 4 FROM gonsole.records", + prints: []string{"account:grant-role", "account:role", "account:disable", "account:enable"}}, + } { + db.runLine(p, line) + } +} diff --git a/gonsole/auth/postgres/contract_test.go b/gonsole/auth/postgres/contract_test.go new file mode 100644 index 0000000..80f7544 --- /dev/null +++ b/gonsole/auth/postgres/contract_test.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres_test + +import ( + "context" + "io" + "maps" + "testing" + "time" + + authkitpostgres "github.com/gopherium/gouncer/authkit/postgres" + + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/auth/postgres" + "github.com/gopherium/framework/gonsole/auth/recordstest" +) + +// plantAt inserts one record as applied at a given time, finding the account the acting address names. +const plantAt = `INSERT INTO gonsole.records (id, applied_at, actor, account_id, command, args, flags) +VALUES ($1::uuid, $2, $3, (SELECT id FROM auth.users WHERE email = $3), $4, $5::jsonb, $6::jsonb)` + +var _ auth.Accounts = (*authkitpostgres.UserStore)(nil) + +// recordsFixture returns the contract fixture of a record store over a fresh database holding the accounts only. +func recordsFixture(t *testing.T) recordstest.Fixture { + t.Helper() + db := migrated(t) + pool := db.pool() + accounts := authkitpostgres.NewUserStore(pool) + return recordstest.Fixture{ + Records: postgres.NewRecordStore(pool), + Migrate: func(ctx context.Context) error { return postgres.RecordMigration().Run(ctx, db.address) }, + Account: func(ctx context.Context, email string) (string, error) { + held := auth.Account{Email: email, Name: "Maria Perez", Password: demoPassword, Role: "admin"} + if err := auth.EnsureAccounts(ctx, accounts, []auth.Account{held}, io.Discard); err != nil { + return "", err + } + user, err := accounts.UserByEmail(ctx, email) + return user.ID.String(), err + }, + Plant: func(ctx context.Context, entry auth.Entry, appliedAt time.Time) error { + flags := map[string]string{} + maps.Copy(flags, entry.Flags) + _, err := pool.Exec(ctx, plantAt, entry.ID, appliedAt, entry.Actor, entry.Command, + append([]string{}, entry.Args...), flags) + return err + }, + } +} + +func TestPostgresRecordsKeepTheContract(t *testing.T) { + t.Parallel() + + recordstest.Run(t, recordsFixture) +} diff --git a/gonsole/auth/postgres/doc.go b/gonsole/auth/postgres/doc.go new file mode 100644 index 0000000..c1fd5c0 --- /dev/null +++ b/gonsole/auth/postgres/doc.go @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package postgres runs the account commands and command records of gonsole/auth on a program's PostgreSQL database. +package postgres diff --git a/gonsole/auth/postgres/failures_test.go b/gonsole/auth/postgres/failures_test.go new file mode 100644 index 0000000..be8b7a0 --- /dev/null +++ b/gonsole/auth/postgres/failures_test.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/gopherium/framework/dbkit" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/auth/postgres" +) + +// badArgs stores a record whose arguments are a JSON object, past the record store. +const badArgs = "INSERT INTO gonsole.records (id, actor, command, args, flags) VALUES " + + "('019a0000-0000-7000-8000-000000000001', 'admin@example.com', 'account:role', '{}', '{}')" + +// wantUnder fails t unless err is a failure under the action the store names. +func wantUnder(t *testing.T, err error, action string) { + t.Helper() + if prefix := "postgres: " + action + ": "; !strings.HasPrefix(fmt.Sprint(err), prefix) { + t.Errorf("the call answered %v, want a failure under %q", err, prefix) + } +} + +func TestARepeatedRecordIDAnswersDbkitsUniqueClass(t *testing.T) { + t.Parallel() + + store := postgres.NewRecordStore(recorded(t).pool()) + mustInsert(t, store, roleChange()) + + err := store.Insert(t.Context(), roleChange()) + + wantUnder(t, err, "insert a record") + if !errors.Is(err, dbkit.ErrUnique) { + t.Errorf("Insert() of a repeated id = %v, want dbkit.ErrUnique", err) + } +} + +func TestHeldOnAClosedPoolFailsUnderItsAction(t *testing.T) { + t.Parallel() + + pool := recorded(t).pool() + pool.Close() + + _, err := postgres.NewRecordStore(pool).Held(t.Context()) + + wantUnder(t, err, "check the records") +} + +func TestEveryCallOnAMissingTableFailsUnderItsAction(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + action string + call func(ctx context.Context, store auth.RecordStore) error + }{ + {"insert a record", func(ctx context.Context, store auth.RecordStore) error { + return store.Insert(ctx, roleChange()) + }}, + {"list the records", func(ctx context.Context, store auth.RecordStore) error { + _, err := store.Latest(ctx, 10) + return err + }}, + } { + t.Run(tc.action, func(t *testing.T) { + t.Parallel() + db := recorded(t) + db.run("DROP TABLE gonsole.records") + + err := tc.call(t.Context(), postgres.NewRecordStore(db.pool())) + + wantUnder(t, err, tc.action) + }) + } +} + +func TestARecordThatListsBadlyFailsTheWholeList(t *testing.T) { + t.Parallel() + + db := recorded(t) + db.run(badArgs) + + listed, err := postgres.NewRecordStore(db.pool()).Latest(t.Context(), 10) + + wantUnder(t, err, "list the records") + if listed != nil { + t.Errorf("Latest() over a record with object arguments = %v, want no list", listed) + } +} diff --git a/gonsole/auth/postgres/go.mod b/gonsole/auth/postgres/go.mod new file mode 100644 index 0000000..c86f8c3 --- /dev/null +++ b/gonsole/auth/postgres/go.mod @@ -0,0 +1,30 @@ +module github.com/gopherium/framework/gonsole/auth/postgres + +go 1.27.2 + +require ( + github.com/gopherium/framework/dbkit v0.1.0 + github.com/gopherium/framework/dbkit/postgres v0.3.0 + github.com/gopherium/framework/gonsole v0.6.1 + github.com/gopherium/framework/gonsole/auth v0.7.0 + github.com/gopherium/gouncer v0.4.0 + github.com/gopherium/gouncer/authkit/postgres v0.13.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/peterldowns/pgtestdb v0.1.1 + github.com/pressly/goose/v3 v3.27.2 +) + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/gopherium/gouncer/authkit v0.19.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect +) diff --git a/gonsole/auth/postgres/go.sum b/gonsole/auth/postgres/go.sum new file mode 100644 index 0000000..a884cda --- /dev/null +++ b/gonsole/auth/postgres/go.sum @@ -0,0 +1,82 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherium/framework/dbkit v0.1.0 h1:jeFld4uZu555PkgbZQRKkDK1WOOB8am+Wu2Ar4xX9Jw= +github.com/gopherium/framework/dbkit v0.1.0/go.mod h1:M837GTBK4MLEt0oqvEKhCQydKFNhVLWvsDlAcqT21co= +github.com/gopherium/framework/dbkit/postgres v0.3.0 h1:W9SoXXu9Y3YRum5vIG10iBHlMMIhmbuUmOPLmZEnW3U= +github.com/gopherium/framework/dbkit/postgres v0.3.0/go.mod h1:ZUsvp2lOkhX+08sstBErt+jN6FM5HkhxV7hXgToRd+A= +github.com/gopherium/framework/gonsole v0.6.1 h1:yi0te8BDJLuotbrd24bQDMWSJ0KgIhSIAfOjF0jpDnQ= +github.com/gopherium/framework/gonsole v0.6.1/go.mod h1:n0aJzbwPGPryrWKZ8WzDVub+6R+GUBzeHSzvhMvZR64= +github.com/gopherium/framework/gonsole/auth v0.7.0 h1:e1s0LJlLK2zPmrzexzMix7pkiTIXqRe0bd83MzYsRiE= +github.com/gopherium/framework/gonsole/auth v0.7.0/go.mod h1:t9PxTMk18NqcoXgxokdUQnOvNPYfcW2HXVjx8fPxei0= +github.com/gopherium/gouncer v0.4.0 h1:dkbCD5oeYuuzR961UKGsAvnTvF8vTstlFbN94rP4TvI= +github.com/gopherium/gouncer v0.4.0/go.mod h1:WwPOu8LyRikot0EODySSu46zaHcWTPbl5nOes4I3H2U= +github.com/gopherium/gouncer/authkit v0.19.0 h1:4TPPakAHNmqG0H+7c62YomNgDfd7uRbuccIXvbdutKc= +github.com/gopherium/gouncer/authkit v0.19.0/go.mod h1:oJP/pk0xnXA4HhOlFoqm4lh9ulScCUbgGeLFrquZ6g8= +github.com/gopherium/gouncer/authkit/postgres v0.13.0 h1:9uN7xZirufk0osM/LxMj0K1L8ZZ5MrUBsSFze/SuurA= +github.com/gopherium/gouncer/authkit/postgres v0.13.0/go.mod h1:wnA/EmvSqV3w8n3L4EYTejY1bnYjP7Cd7O+sRtf9bkw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pashagolub/pgxmock/v4 v4.9.0 h1:itlO8nrVRnzkdMBXLs8pWUyyB2PC3Gku0WGIj/gGl7I= +github.com/pashagolub/pgxmock/v4 v4.9.0/go.mod h1:9L57pC193h2aKRHVyiiE817avasIPZnPwPlw3JczWvM= +github.com/peterldowns/pgtestdb v0.1.1 h1:+hBCD1DcbKeg5Sfg0G+5WNIy/Cm0ORgwMkF4ygihrmU= +github.com/peterldowns/pgtestdb v0.1.1/go.mod h1:yVWInWV0dxvmLdL2ao3nXDzWZ9+G6EhJ4gRwvI1Ozeg= +github.com/peterldowns/testy v0.0.1 h1:9a6LzvnKcL52Crzud1z7jbsAojTntCh89ho6mgsr4KU= +github.com/peterldowns/testy v0.0.1/go.mod h1:J4sm75UEzbfBIcq0zbrshWWjsJQiJ5RrhTPYKVY2Ww8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= +github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= diff --git a/gonsole/auth/postgres/helpers_test.go b/gonsole/auth/postgres/helpers_test.go new file mode 100644 index 0000000..af5e50a --- /dev/null +++ b/gonsole/auth/postgres/helpers_test.go @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres_test + +import ( + "context" + "fmt" + "net/url" + "os" + "testing" + "time" + + "github.com/gopherium/gouncer/authkit/postgres/testdb" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/peterldowns/pgtestdb" + "github.com/pressly/goose/v3/lock" + + "github.com/gopherium/framework/dbkit/postgres/pgtest" + "github.com/gopherium/framework/gonsole/auth/postgres" +) + +const ( + // serverVariable names the environment variable that holds the address of the test server. + serverVariable = "DBKIT_TEST_POSTGRES_URL" + // demoPassword is the password of every account the tests create. + demoPassword = "correct horse battery" + // newSecret is a part of an address password that no message may hold. + newSecret = "secretword" + // tableLookup answers whether the table its one argument names exists. + tableLookup = "SELECT to_regclass($1) IS NOT NULL" + // lockTaking takes goose's migration lock. + lockTaking = "SELECT pg_advisory_lock($1)" + // lockRelease lets goose's migration lock go. + lockRelease = "SELECT pg_advisory_unlock($1)" + // sessionLookup answers whether a session other than this one and the holder's has a column like a pattern. + sessionLookup = "SELECT EXISTS (SELECT FROM pg_stat_activity WHERE datname = current_database()" + + " AND pid NOT IN (pg_backend_pid(), $1) AND %s LIKE $2)" + // noOtherSession answers whether no client session but this one is on the database. + noOtherSession = "SELECT NOT EXISTS (SELECT FROM pg_stat_activity WHERE datname = current_database()" + + " AND backend_type = 'client backend' AND pid <> pg_backend_pid())" + // sessionBound is how long noSessionLeft waits for the sessions of a run to end. + sessionBound = 5 * time.Second +) + +// server is the address of the test server. +var server string + +// TestMain runs the tests once serverVariable holds an address pgtest takes. +func TestMain(m *testing.M) { + server = os.Getenv(serverVariable) + if err := pgtest.CheckAddress(server); err != nil { + _, _ = fmt.Fprintf(os.Stderr, + "gonsole/auth/postgres: the tests need %s, the address of a PostgreSQL server pgtest takes: %v\n", + serverVariable, err) + os.Exit(1) + } + os.Exit(m.Run()) +} + +// sessionState is the column of pg_stat_activity a waiting session shows and the pattern it matches. +type sessionState struct { + // column is the column of pg_stat_activity. + column string + // pattern is the LIKE pattern the column matches. + pattern string +} + +var ( + // waitingOnLock is a session waiting on a lock. + waitingOnLock = sessionState{column: "wait_event_type", pattern: "Lock"} + // waitingOnAdvisory is a session asking for an advisory lock. + waitingOnAdvisory = sessionState{column: "query", pattern: "%advisory%"} +) + +// testDB is one fresh database of one test. +type testDB struct { + // t is the test the database belongs to. + t *testing.T + // address is the address of the database. + address string +} + +// empty returns a fresh database holding no schema. +func empty(t *testing.T) testDB { + t.Helper() + return testDB{t: t, address: pgtest.New(t, server, pgtestdb.NoopMigrator{})} +} + +// migrated returns a fresh database holding the account schema and no records table. +func migrated(t *testing.T) testDB { + t.Helper() + return testDB{t: t, address: pgtest.New(t, server, testdb.Migrator())} +} + +// recorded returns a fresh database holding the account schema and the records table. +func recorded(t *testing.T) testDB { + t.Helper() + db := migrated(t) + db.apply(postgres.RecordMigration()) + return db +} + +// withQuery returns db at an address whose query sets key to value. +func (db testDB) withQuery(key, value string) testDB { + db.t.Helper() + address, err := url.Parse(db.address) + if err != nil { + db.t.Fatalf("parsing the test database address: %v", err) + } + query := address.Query() + query.Set(key, value) + address.RawQuery = query.Encode() + return testDB{t: db.t, address: address.String()} +} + +// dial returns a new connection to db, which the caller closes. +func (db testDB) dial() *pgx.Conn { + db.t.Helper() + conn, err := pgx.Connect(db.t.Context(), db.address) + if err != nil { + db.t.Fatalf("connecting to the test database: %v", err) + } + return conn +} + +// connect returns a connection to db, closed when the test ends. +func (db testDB) connect() *pgx.Conn { + db.t.Helper() + conn := db.dial() + db.t.Cleanup(func() { _ = conn.Close(context.Background()) }) + return conn +} + +// run executes each statement on db, failing the test at the first that fails. +func (db testDB) run(statements ...string) { + db.t.Helper() + conn := db.dial() + defer func() { _ = conn.Close(context.Background()) }() + for _, statement := range statements { + if _, err := conn.Exec(db.t.Context(), statement); err != nil { + db.t.Fatalf("%s: %v", statement, err) + } + } +} + +// exists reports whether db holds the table named name. +func (db testDB) exists(name string) bool { + db.t.Helper() + return db.found(tableLookup, name) +} + +// found reports whether lookup answers true for values on db. +func (db testDB) found(lookup string, values ...any) bool { + db.t.Helper() + conn := db.dial() + defer func() { _ = conn.Close(context.Background()) }() + var held bool + if err := conn.QueryRow(db.t.Context(), lookup, values...).Scan(&held); err != nil { + db.t.Fatalf("%s: %v", lookup, err) + } + return held +} + +// pool returns a pool on db, closed when the test ends. +func (db testDB) pool() *pgxpool.Pool { + db.t.Helper() + pool, err := pgxpool.New(db.t.Context(), db.address) + if err != nil { + db.t.Fatalf("opening a pool on the test database: %v", err) + } + db.t.Cleanup(pool.Close) + return pool +} + +// holdMigrationLock returns a connection holding goose's migration lock on db. +func (db testDB) holdMigrationLock() *pgx.Conn { + db.t.Helper() + conn := db.connect() + if _, err := conn.Exec(db.t.Context(), lockTaking, lock.DefaultLockID); err != nil { + db.t.Fatalf("holding the migration lock: %v", err) + } + return conn +} + +// noSessionLeft fails the test unless every client session but its own leaves db within sessionBound. +func (db testDB) noSessionLeft() { + db.t.Helper() + deadline := time.Now().Add(sessionBound) + for !db.found(noOtherSession) { + if time.Now().After(deadline) { + db.t.Fatalf("a session stayed on the test database %v after the run, want every pool closed", sessionBound) + } + time.Sleep(10 * time.Millisecond) + } +} + +// awaitSession returns once another session than holder's is in state on db, failing when ran ends or time runs out. +func (db testDB) awaitSession(holder *pgx.Conn, ran <-chan error, state sessionState) { + db.t.Helper() + lookup := fmt.Sprintf(sessionLookup, state.column) + deadline := time.After(sessionBound) + for !db.found(lookup, int32(holder.PgConn().PID()), state.pattern) { + select { + case err := <-ran: + db.t.Fatalf("Run() = %v before a session had %s like %s", err, state.column, state.pattern) + case <-deadline: + db.t.Fatalf("no session had %s like %s within %v, want the step to reach it", state.column, state.pattern, + sessionBound) + case <-time.After(10 * time.Millisecond): + } + } +} diff --git a/gonsole/auth/postgres/lineage_test.go b/gonsole/auth/postgres/lineage_test.go new file mode 100644 index 0000000..3d9efbb --- /dev/null +++ b/gonsole/auth/postgres/lineage_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres_test + +import ( + "context" + "testing" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" +) + +const ( + // versionsLookup lists the applied versions of both version tables on one line. + versionsLookup = "SELECT (SELECT string_agg(version_id || ':' || is_applied, ',' ORDER BY id) " + + "FROM auth.goose_db_version) || ' ' || (SELECT string_agg(version_id || ':' || is_applied, ',' ORDER BY id) " + + "FROM gonsole.goose_db_version)" + // plantRecord stores one record past every record store. + plantRecord = "INSERT INTO gonsole.records (id, actor, command, args, flags) VALUES " + + "('019a0000-0000-7000-8000-000000000001', 'admin@example.com', 'account:disable', '[]', '{}')" + // oneRecord answers whether gonsole.records holds exactly one row. + oneRecord = "SELECT count(*) = 1 FROM gonsole.records" +) + +// text returns the single text lookup answers on db. +func (db testDB) text(lookup string) string { + db.t.Helper() + conn := db.dial() + defer func() { _ = conn.Close(context.Background()) }() + var value string + if err := conn.QueryRow(db.t.Context(), lookup).Scan(&value); err != nil { + db.t.Fatalf("%s: %v", lookup, err) + } + return value +} + +func TestAProgramThatSwitchesStepsAppliesNothingTwice(t *testing.T) { + t.Parallel() + + core := []gonsole.Step{auth.Migration(), auth.RecordMigration()} + for _, tc := range []struct { + name string + first []gonsole.Step + second []gonsole.Step + }{ + {"from gonsole/auth to the module", core, steps()}, + {"from the module to gonsole/auth", steps(), core}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + db := empty(t) + db.apply(tc.first...) + db.run(plantRecord) + before := db.text(versionsLookup) + + db.apply(tc.second...) + + if after := db.text(versionsLookup); after != before { + t.Errorf("the version tables read %q after the other steps, want %q as before", after, before) + } + if !db.found(oneRecord) { + t.Error("the planted record is gone after the other steps, want it kept") + } + }) + } +} diff --git a/gonsole/auth/postgres/migrations/00001_create_records.sql b/gonsole/auth/postgres/migrations/00001_create_records.sql new file mode 100644 index 0000000..2205b02 --- /dev/null +++ b/gonsole/auth/postgres/migrations/00001_create_records.sql @@ -0,0 +1,19 @@ +-- SPDX-License-Identifier: Apache-2.0 + +-- +goose Up +CREATE SCHEMA IF NOT EXISTS gonsole; + +CREATE TABLE gonsole.records ( + id uuid PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now(), + actor text NOT NULL, + account_id uuid, + command text NOT NULL, + args jsonb NOT NULL, + flags jsonb NOT NULL +); + +CREATE INDEX records_applied_at ON gonsole.records (applied_at DESC); + +-- +goose Down +DROP TABLE gonsole.records; diff --git a/gonsole/auth/postgres/numbers_test.go b/gonsole/auth/postgres/numbers_test.go new file mode 100644 index 0000000..7c547fa --- /dev/null +++ b/gonsole/auth/postgres/numbers_test.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres_test + +import ( + "errors" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// migrationContents returns the contents of each migration file in folder, keyed by its name. +func migrationContents(t *testing.T, folder string) map[string]string { + t.Helper() + entries, err := os.ReadDir(folder) + if err != nil { + t.Fatalf("reading %s: %v", folder, err) + } + contents := map[string]string{} + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + data, err := os.ReadFile(filepath.Join(folder, entry.Name())) + if err != nil { + t.Fatalf("reading %s: %v", entry.Name(), err) + } + contents[entry.Name()] = string(data) + } + return contents +} + +func TestRecordsMigrationsMatchGonsoleAuthByteForByte(t *testing.T) { + t.Parallel() + + if _, err := os.Stat(filepath.Join("..", "go.mod")); errors.Is(err, os.ErrNotExist) { + t.Skip("gonsole/auth is not beside this module") + } + + module := migrationContents(t, "migrations") + core := migrationContents(t, filepath.Join("..", "migrations")) + + if !maps.Equal(module, core) { + t.Errorf("the records migrations are %v here and %v in gonsole/auth, want the same files byte for byte", + slices.Sorted(maps.Keys(module)), slices.Sorted(maps.Keys(core))) + } +} diff --git a/gonsole/auth/postgres/parity_test.go b/gonsole/auth/postgres/parity_test.go new file mode 100644 index 0000000..a8766f4 --- /dev/null +++ b/gonsole/auth/postgres/parity_test.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres_test + +import ( + "regexp" + "testing" + + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/testkit" +) + +var ( + // stamps matches the times a line prints. + stamps = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})`) + // ids matches the UUIDs a line prints. + ids = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) +) + +// answer is what one line answered, its times and ids cut. +type answer struct { + // code is the exit code. + code int + // stdout is what the line printed. + stdout string + // stderr is what the line printed as failure. + stderr string +} + +// cut returns the answer of result with each time and id replaced by a placeholder. +func cut(result testkit.Result) answer { + clean := func(text string) string { return ids.ReplaceAllString(stamps.ReplaceAllString(text, "