From e1def1860b071b33dc5a923ca5ffbd239a49c0b7 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 20:15:12 +0200 Subject: [PATCH 01/13] feat(gonsole/auth/postgres): add the module with the accounts and records schema steps --- gonsole/auth/postgres/doc.go | 4 + gonsole/auth/postgres/go.mod | 30 ++ gonsole/auth/postgres/go.sum | 82 ++++++ gonsole/auth/postgres/helpers_test.go | 209 +++++++++++++ gonsole/auth/postgres/lineage_test.go | 67 +++++ .../migrations/00001_create_records.sql | 19 ++ gonsole/auth/postgres/schema.go | 66 +++++ gonsole/auth/postgres/schema_internal_test.go | 17 ++ gonsole/auth/postgres/schema_test.go | 277 ++++++++++++++++++ 9 files changed, 771 insertions(+) create mode 100644 gonsole/auth/postgres/doc.go create mode 100644 gonsole/auth/postgres/go.mod create mode 100644 gonsole/auth/postgres/go.sum create mode 100644 gonsole/auth/postgres/helpers_test.go create mode 100644 gonsole/auth/postgres/lineage_test.go create mode 100644 gonsole/auth/postgres/migrations/00001_create_records.sql create mode 100644 gonsole/auth/postgres/schema.go create mode 100644 gonsole/auth/postgres/schema_internal_test.go create mode 100644 gonsole/auth/postgres/schema_test.go 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/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..7a0bed0 --- /dev/null +++ b/gonsole/auth/postgres/helpers_test.go @@ -0,0 +1,209 @@ +// 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 a session other than holder is in state on db, failing if ran ends first. +func (db testDB) awaitSession(holder *pgx.Conn, ran <-chan error, state sessionState) { + db.t.Helper() + lookup := fmt.Sprintf(sessionLookup, state.column) + 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 <-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/schema.go b/gonsole/auth/postgres/schema.go new file mode 100644 index 0000000..0dfa3eb --- /dev/null +++ b/gonsole/auth/postgres/schema.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres + +import ( + "context" + "embed" + "errors" + "fmt" + "io/fs" + + authkitpostgres "github.com/gopherium/gouncer/authkit/postgres" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + + dbpostgres "github.com/gopherium/framework/dbkit/postgres" + "github.com/gopherium/framework/gonsole" +) + +// migrationFiles holds the migrations of the command records. +// +//go:embed migrations/*.sql +var migrationFiles embed.FS + +// recordsVersionTable keeps the lineage of the records migrations inside their own schema. +const recordsVersionTable = "gonsole.goose_db_version" + +// errUnreadable refuses an address pgx cannot parse, and holds none of pgx's text. +var errUnreadable = errors.New("postgres: open the database: pgx cannot parse the address or one of its settings") + +// migrationSource holds the records migrations at its root. +var migrationSource = mustSub(migrationFiles, "migrations") + +// Migration returns the step that applies gouncer's account schema. +func Migration() gonsole.Step { + return gonsole.Step{Name: "accounts", Run: authkitpostgres.Migrate} +} + +// RecordMigration returns the step that applies the schema keeping the command records. +func RecordMigration() gonsole.Step { + return gonsole.Step{Name: "records", Run: migrateRecords} +} + +// migrateRecords applies the records migrations to the database at databaseURL. +func migrateRecords(ctx context.Context, databaseURL string) error { + config, err := pgx.ParseConfig(databaseURL) + if err != nil { + return errUnreadable + } + db := stdlib.OpenDB(*config) + defer func() { _ = db.Close() }() + err = dbpostgres.Migrate(ctx, db, dbpostgres.Migrations{Table: recordsVersionTable, FS: migrationSource}) + if err != nil { + return fmt.Errorf("postgres: apply the records schema: %w", dbpostgres.Classify(err)) + } + return nil +} + +// mustSub returns the subtree of fsys at dir, and panics when dir is not a valid path. +func mustSub(fsys fs.FS, dir string) fs.FS { + sub, err := fs.Sub(fsys, dir) + if err != nil { + panic(err) + } + return sub +} diff --git a/gonsole/auth/postgres/schema_internal_test.go b/gonsole/auth/postgres/schema_internal_test.go new file mode 100644 index 0000000..9ab0746 --- /dev/null +++ b/gonsole/auth/postgres/schema_internal_test.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres + +import "testing" + +func TestMustSubPanicsOnAnInvalidPath(t *testing.T) { + t.Parallel() + + defer func() { + if recover() == nil { + t.Error("mustSub() returned, want a panic on an invalid path") + } + }() + + mustSub(migrationFiles, "..") +} diff --git a/gonsole/auth/postgres/schema_test.go b/gonsole/auth/postgres/schema_test.go new file mode 100644 index 0000000..f0aff36 --- /dev/null +++ b/gonsole/auth/postgres/schema_test.go @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres_test + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + authkitpostgres "github.com/gopherium/gouncer/authkit/postgres" + "github.com/pressly/goose/v3/lock" + + "github.com/gopherium/framework/dbkit" + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth/postgres" +) + +// steps returns the two schema steps of the module in the order a program runs them. +func steps() []gonsole.Step { + return []gonsole.Step{postgres.Migration(), postgres.RecordMigration()} +} + +// apply runs each step on db, failing the test at the first that fails. +func (db testDB) apply(steps ...gonsole.Step) { + db.t.Helper() + for _, step := range steps { + if err := step.Run(db.t.Context(), db.address); err != nil { + db.t.Fatalf("running the %s step: %v", step.Name, err) + } + } +} + +func TestMigrationAppliesTheAccountSchemaAsOftenAsItRuns(t *testing.T) { + t.Parallel() + + db := empty(t) + step := postgres.Migration() + + db.apply(step, step) + + if step.Name != "accounts" { + t.Errorf("Name = %q, want accounts", step.Name) + } + if _, err := authkitpostgres.NewUserStore(db.pool()).ListUsers(t.Context()); err != nil { + t.Errorf("ListUsers() after the migration = %v, want the account schema in place", err) + } +} + +func TestRecordMigrationCreatesTheRecordsAsOftenAsItRuns(t *testing.T) { + t.Parallel() + + db := empty(t) + step := postgres.RecordMigration() + + db.apply(step, step) + + if step.Name != "records" { + t.Errorf("Name = %q, want records", step.Name) + } + if !db.exists("gonsole.records") || !db.exists("gonsole.goose_db_version") { + t.Errorf("gonsole.records and its version table are missing after the migration") + } +} + +func TestBothStepsKeepTheirOwnVersionTables(t *testing.T) { + t.Parallel() + + db := empty(t) + + db.apply(steps()...) + + if !db.exists("auth.goose_db_version") || !db.exists("gonsole.goose_db_version") { + t.Errorf("the steps left auth.goose_db_version %t and gonsole.goose_db_version %t, want both", + db.exists("auth.goose_db_version"), db.exists("gonsole.goose_db_version")) + } + if !db.found("SELECT max(version_id) = 1 FROM gonsole.goose_db_version") { + t.Error("gonsole.goose_db_version holds no version 1 as its highest, want the one records migration") + } +} + +func TestEveryStepWaitsForTheMigrationLock(t *testing.T) { + t.Parallel() + + tests := []struct { + step gonsole.Step + table string + }{ + {postgres.Migration(), "auth.users"}, + {postgres.RecordMigration(), "gonsole.records"}, + } + for _, tt := range tests { + t.Run(tt.step.Name, func(t *testing.T) { + t.Parallel() + + db := empty(t) + db.holdMigrationLock() + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + + err := tt.step.Run(ctx, db.address) + + if !errors.Is(err, context.DeadlineExceeded) || db.exists(tt.table) { + t.Errorf("Run() = %v, want the deadline and no %s table", err, tt.table) + } + }) + } +} + +func TestEveryStepLetsRunsAtOnceAllSucceed(t *testing.T) { + t.Parallel() + + for _, step := range steps() { + t.Run(step.Name, func(t *testing.T) { + t.Parallel() + + db := empty(t) + failures := make(chan error, 4) + var runs sync.WaitGroup + for range 4 { + runs.Go(func() { failures <- step.Run(t.Context(), db.address) }) + } + runs.Wait() + close(failures) + + var all []error + for err := range failures { + all = append(all, err) + } + if err := errors.Join(all...); err != nil { + t.Errorf("Run() = %v, want every run to succeed", err) + } + }) + } +} + +func TestEveryStepSucceedsWhenAnotherSessionCreatesItsSchemaFirst(t *testing.T) { + t.Parallel() + + tests := []struct { + step gonsole.Step + schema string + }{ + {postgres.Migration(), "auth"}, + {postgres.RecordMigration(), "gonsole"}, + } + for _, tt := range tests { + t.Run(tt.step.Name, func(t *testing.T) { + t.Parallel() + + db := empty(t) + holder := db.connect() + tx, err := holder.Begin(t.Context()) + if err != nil { + t.Fatalf("beginning: %v", err) + } + if _, err := tx.Exec(t.Context(), "CREATE SCHEMA "+tt.schema); err != nil { + t.Fatalf("creating the %s schema: %v", tt.schema, err) + } + ran := make(chan error, 1) + go func() { ran <- tt.step.Run(t.Context(), db.address) }() + db.awaitSession(holder, ran, waitingOnLock) + + if err := tx.Commit(t.Context()); err != nil { + t.Fatalf("committing the %s schema: %v", tt.schema, err) + } + + if err := <-ran; err != nil { + t.Errorf("Run() = %v, want it to succeed", err) + } + }) + } +} + +func TestEveryStepLetsAConcurrentIndexBuildFinishWhileItWaits(t *testing.T) { + t.Parallel() + + for _, step := range steps() { + t.Run(step.Name, func(t *testing.T) { + t.Parallel() + + db := empty(t) + db.run("CREATE TABLE items (x int)") + holder := db.holdMigrationLock() + ran := make(chan error, 1) + go func() { ran <- step.Run(t.Context(), db.address) }() + db.awaitSession(holder, ran, waitingOnAdvisory) + + _, built := holder.Exec(t.Context(), "CREATE INDEX CONCURRENTLY items_x ON items (x)") + if _, err := holder.Exec(t.Context(), lockRelease, lock.DefaultLockID); err != nil { + t.Fatalf("releasing the migration lock: %v", err) + } + + if err := <-ran; built != nil || err != nil { + t.Errorf("index build = %v and Run() = %v, want both to finish", built, err) + } + }) + } +} + +func TestRecordMigrationNamesEveryFailureUnderItsAction(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + address func(t *testing.T) string + want string + }{ + {"an address it cannot read", func(*testing.T) string { return "postgres://%zz" }, + "postgres: open the database: "}, + {"a database it cannot reach", func(*testing.T) string { + return "postgres://postgres@127.0.0.1:1/none?connect_timeout=1" + }, "postgres: apply the records schema: "}, + {"a step it cannot apply", func(t *testing.T) string { + db := empty(t) + db.run("CREATE SCHEMA gonsole", "CREATE TABLE gonsole.records (id int)") + return db.address + }, "postgres: apply the records schema: "}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := postgres.RecordMigration().Run(t.Context(), tc.address(t)) + + if !strings.HasPrefix(fmt.Sprint(err), tc.want) { + t.Errorf("Run() = %v, want the failure under %q", err, tc.want) + } + }) + } +} + +func TestRecordMigrationNamesAnUnreadableAddressWithoutItsPassword(t *testing.T) { + t.Parallel() + + err := postgres.RecordMigration().Run(t.Context(), "postgres://u:"+newSecret+"/tail@localhost:5434/app") + + message := fmt.Sprint(err) + if !strings.HasPrefix(message, "postgres: open the database: ") || strings.Contains(message, newSecret) { + t.Errorf("Run() = %v, want the database named and no part of the password", err) + } +} + +func TestTheRecordsStepHeldPastItsLockTimeoutAnswersDbkitsBusyClass(t *testing.T) { + t.Parallel() + + db := recorded(t) + holder := db.connect() + lockVersions := "BEGIN; LOCK TABLE gonsole.goose_db_version IN ACCESS EXCLUSIVE MODE" + if _, err := holder.Exec(t.Context(), lockVersions); err != nil { + t.Fatalf("locking the version table: %v", err) + } + + err := postgres.RecordMigration().Run(t.Context(), db.withQuery("lock_timeout", "100ms").address) + + if !errors.Is(err, dbkit.ErrBusy) || !strings.HasPrefix(fmt.Sprint(err), "postgres: apply the records schema: ") { + t.Errorf("the records step behind a held version table = %v, want dbkit.ErrBusy under the step", err) + } +} + +func TestRecordMigrationNamesTheSchemaItCannotCreate(t *testing.T) { + t.Parallel() + + db := empty(t) + db.run("DO $$ BEGIN EXECUTE format(" + + "'ALTER DATABASE %I SET default_transaction_read_only = on', current_database()); END $$") + + err := postgres.RecordMigration().Run(t.Context(), db.address) + + message := fmt.Sprint(err) + if !strings.HasPrefix(message, "postgres: apply the records schema: ") || + !strings.Contains(message, "dbkit: create the schema gonsole: ") { + t.Errorf("Run() = %v, want the gonsole schema named under the records schema", err) + } +} From 99b1bc4744e9ee45f07803a1114a9c8865567099 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 20:15:12 +0200 Subject: [PATCH 02/13] test(gonsole/auth/postgres): keep the records migrations identical to those of gonsole/auth --- gonsole/auth/postgres/numbers_test.go | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 gonsole/auth/postgres/numbers_test.go 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))) + } +} From b389623a2a4fff528f2cc078282a302b136bbce5 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 20:15:13 +0200 Subject: [PATCH 03/13] chore(gonsole/auth/postgres): lint the module with strict import lists and forbidigo --- gonsole/auth/postgres/.golangci.yml | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 gonsole/auth/postgres/.golangci.yml 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 From ae5349553f304e1aad4587abf80f2a1d67a56480 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 20:15:13 +0200 Subject: [PATCH 04/13] feat(gonsole/auth/postgres): keep the command records in gonsole.records and pass recordstest --- gonsole/auth/postgres/contract_test.go | 56 ++++++++++++++++ gonsole/auth/postgres/records.go | 92 ++++++++++++++++++++++++++ gonsole/auth/postgres/records_test.go | 83 +++++++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 gonsole/auth/postgres/contract_test.go create mode 100644 gonsole/auth/postgres/records.go create mode 100644 gonsole/auth/postgres/records_test.go 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/records.go b/gonsole/auth/postgres/records.go new file mode 100644 index 0000000..41b62db --- /dev/null +++ b/gonsole/auth/postgres/records.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres + +import ( + "context" + "encoding/json" + "fmt" + "maps" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + dbpostgres "github.com/gopherium/framework/dbkit/postgres" + "github.com/gopherium/framework/gonsole/auth" +) + +// heldRecords reads whether the database holds the gonsole.records table. +const heldRecords = `SELECT to_regclass('gonsole.records') IS NOT NULL` + +// storeRecord inserts one record, finding the account the acting address names. +const storeRecord = `INSERT INTO gonsole.records (id, actor, account_id, command, args, flags) +VALUES ($1::uuid, $2, (SELECT id FROM auth.users WHERE email = $2), $3, $4::jsonb, $5::jsonb)` + +// latestRecords reads the newest records first, as many as the limit allows. +const latestRecords = `SELECT id::text, applied_at, actor, account_id::text, command, args, flags +FROM gonsole.records ORDER BY applied_at DESC, id DESC LIMIT $1` + +// recordStore is the record store of the gonsole.records table in a program's PostgreSQL database. +type recordStore struct { + // pool runs the statements. + pool *pgxpool.Pool +} + +// NewRecordStore returns the record store of the PostgreSQL database behind pool. +func NewRecordStore(pool *pgxpool.Pool) auth.RecordStore { + return recordStore{pool: pool} +} + +// Held reports whether the database holds the gonsole.records table. +func (r recordStore) Held(ctx context.Context) (bool, error) { + var held bool + if err := r.pool.QueryRow(ctx, heldRecords).Scan(&held); err != nil { + return false, failed("check the records", err) + } + return held, nil +} + +// Insert stores entry as applied now on the server's clock, finding the account its actor names in auth.users. +func (r recordStore) Insert(ctx context.Context, entry auth.Entry) error { + args, flags := encoded(entry) + _, err := r.pool.Exec(ctx, storeRecord, entry.ID, entry.Actor, entry.Command, args, flags) + return failed("insert a record", err) +} + +// Latest returns the newest entries first, at most limit of them, and an empty list for none or a limit below zero. +func (r recordStore) Latest(ctx context.Context, limit int) ([]auth.Entry, error) { + rows, err := r.pool.Query(ctx, latestRecords, max(limit, 0)) + if err != nil { + return nil, failed("list the records", err) + } + held, err := pgx.CollectRows(rows, scanned) + if err != nil { + return nil, failed("list the records", err) + } + return held, nil +} + +// scanned returns the entry of row, its arguments and flags decoded from their JSON. +func scanned(row pgx.CollectableRow) (auth.Entry, error) { + var entry auth.Entry + err := row.Scan(&entry.ID, &entry.AppliedAt, &entry.Actor, &entry.AccountID, &entry.Command, &entry.Args, + &entry.Flags) + return entry, err +} + +// encoded returns the JSON text of the entry's arguments and flags, an empty list and object for none. +func encoded(entry auth.Entry) (string, string) { + flags := map[string]string{} + maps.Copy(flags, entry.Flags) + args, _ := json.Marshal(append([]string{}, entry.Args...)) + object, _ := json.Marshal(flags) + return string(args), string(object) +} + +// failed returns nil for a nil err, or err marked with its dbkit class under action. +func failed(action string, err error) error { + if err == nil { + return nil + } + return fmt.Errorf("postgres: %s: %w", action, dbpostgres.Classify(err)) +} diff --git a/gonsole/auth/postgres/records_test.go b/gonsole/auth/postgres/records_test.go new file mode 100644 index 0000000..dc4efab --- /dev/null +++ b/gonsole/auth/postgres/records_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres_test + +import ( + "fmt" + "testing" + + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/auth/postgres" +) + +// recordID is the id of the record the store tests insert. +const recordID = "019a0000-0000-7000-8000-000000000001" + +// roleChange returns an entry of an applied role change by the admin account. +func roleChange() auth.Entry { + return auth.Entry{ID: recordID, Actor: "admin@example.com", Command: "account:role", + Args: []string{"maria@example.com", "editor"}, Flags: map[string]string{"yes": "true"}} +} + +// mustInsert inserts entry through store and fails t when it errs. +func mustInsert(t *testing.T, store auth.RecordStore, entry auth.Entry) { + t.Helper() + if err := store.Insert(t.Context(), entry); err != nil { + t.Fatalf("Insert() error = %v, want nil", err) + } +} + +func TestALimitBelowZeroListsNoRecords(t *testing.T) { + t.Parallel() + + store := postgres.NewRecordStore(recorded(t).pool()) + mustInsert(t, store, roleChange()) + + listed, err := store.Latest(t.Context(), -1) + + if err != nil { + t.Fatalf("Latest(-1) error = %v, want nil", err) + } + if listed == nil || len(listed) != 0 { + t.Errorf("Latest(-1) = %#v, want an empty list", listed) + } +} + +func TestARecordKeepsTheServersTimeOfItsInsert(t *testing.T) { + t.Parallel() + + db := recorded(t) + store := postgres.NewRecordStore(db.pool()) + before := db.text("SELECT now()::text") + + mustInsert(t, store, roleChange()) + + if !db.found("SELECT applied_at BETWEEN $1::timestamptz AND now() FROM gonsole.records", before) { + t.Errorf("the record's applied_at falls outside the server's clock from %s to now, want the time of its insert", + before) + } +} + +func TestAnEntryListsBackUnderEveryQueryMode(t *testing.T) { + t.Parallel() + + for _, mode := range []string{"exec", "simple_protocol"} { + t.Run(mode, func(t *testing.T) { + t.Parallel() + + store := postgres.NewRecordStore(recorded(t).withQuery("default_query_exec_mode", mode).pool()) + mustInsert(t, store, roleChange()) + + listed, err := store.Latest(t.Context(), 10) + + if err != nil || len(listed) != 1 { + t.Fatalf("Latest() = %v, %v, want the one record", listed, err) + } + got, want := listed[0], roleChange() + if fmt.Sprint(got.ID, got.Actor, got.Command, got.Args, got.Flags) != + fmt.Sprint(want.ID, want.Actor, want.Command, want.Args, want.Flags) { + t.Errorf("Latest() lists %+v, want the role change %+v whole", got, want) + } + }) + } +} From 55d53e360ed5b606e5db6cad047c05fbc7bc287f Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 20:15:14 +0200 Subject: [PATCH 05/13] test(gonsole/auth/postgres): class each record store failure under its action --- gonsole/auth/postgres/failures_test.go | 93 ++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 gonsole/auth/postgres/failures_test.go 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) + } +} From 221e6c0e798afe08656d02e30210131db6181aff Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 20:15:14 +0200 Subject: [PATCH 06/13] feat(gonsole/auth/postgres): build the account and record stores on a pool of the database setting --- gonsole/auth/postgres/commands_test.go | 78 ++++++++++++++++++ gonsole/auth/postgres/program_test.go | 62 +++++++++++++++ gonsole/auth/postgres/stores.go | 30 +++++++ gonsole/auth/postgres/stores_test.go | 105 +++++++++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 gonsole/auth/postgres/commands_test.go create mode 100644 gonsole/auth/postgres/program_test.go create mode 100644 gonsole/auth/postgres/stores.go create mode 100644 gonsole/auth/postgres/stores_test.go 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/program_test.go b/gonsole/auth/postgres/program_test.go new file mode 100644 index 0000000..d132335 --- /dev/null +++ b/gonsole/auth/postgres/program_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/gopherium/gouncer" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/auth/postgres" + "github.com/gopherium/framework/gonsole/testkit" +) + +// vocabulary is the role vocabulary of the test program, where only an admin may manage users. +var vocabulary = auth.Roles{ + Known: []string{"admin", "editor", "author"}, Privileged: gouncer.Roles{"admin"}, + Capabilities: map[string][]string{"admin": {"manage_users"}}, +} + +// storesBuilder builds the stores of one call, as auth.Config.Stores does. +type storesBuilder = func(ctx context.Context, call gonsole.Call) (auth.Stores, func(context.Context) error, error) + +// testConfig returns the account commands' configuration on the stores build makes, nil for gonsole/auth's own. +func testConfig(build storesBuilder) auth.Config { + return auth.Config{ + Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { return vocabulary, nil }, + Capability: "manage_users", RecordTimeout: 5 * time.Second, RecordsLimit: 10, Stores: build, + } +} + +// program returns a program called myapp over the database at address, configured by cfg, with migrations. +func program(address string, cfg auth.Config, migrations ...gonsole.Step) gonsole.Program { + return gonsole.Program{ + Name: "myapp", + Env: gonsole.Env{Prefix: "MYAPP_", Getenv: testkit.Getenv(map[string]string{"MYAPP_DATABASE_URL": address})}, + Database: "DATABASE_URL", + Migrations: migrations, + Commands: append(auth.Commands(cfg), auth.Records(cfg)), + Authorize: auth.Authorize(cfg), + Record: auth.Record(cfg), + } +} + +// moduleProgram returns the program over db that runs on the module's stores and schema steps. +func moduleProgram(db testDB) gonsole.Program { + return program(db.address, testConfig(postgres.Stores), steps()...) +} + +// wantPrinted fails t unless out, which command printed, names every part. +func wantPrinted(t *testing.T, command, out string, parts ...string) { + t.Helper() + for _, part := range parts { + if !strings.Contains(out, part) { + t.Errorf("%s printed %q, want it to name %s", command, out, part) + } + } +} diff --git a/gonsole/auth/postgres/stores.go b/gonsole/auth/postgres/stores.go new file mode 100644 index 0000000..e42fb6e --- /dev/null +++ b/gonsole/auth/postgres/stores.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres + +import ( + "context" + + authkitpostgres "github.com/gopherium/gouncer/authkit/postgres" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" +) + +// Stores returns the stores of call on a pool of its database setting, and the release that closes the pool. +func Stores(ctx context.Context, call gonsole.Call) (auth.Stores, func(context.Context) error, error) { + address, err := call.DatabaseURL() + if err != nil { + return auth.Stores{}, nil, err + } + pool, err := pgxpool.New(ctx, address) + if err != nil { + return auth.Stores{}, nil, errUnreadable + } + release := func(context.Context) error { + pool.Close() + return nil + } + return auth.Stores{Accounts: authkitpostgres.NewUserStore(pool), Records: NewRecordStore(pool)}, release, nil +} diff --git a/gonsole/auth/postgres/stores_test.go b/gonsole/auth/postgres/stores_test.go new file mode 100644 index 0000000..c2d59da --- /dev/null +++ b/gonsole/auth/postgres/stores_test.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/auth/postgres" + "github.com/gopherium/framework/gonsole/testkit" +) + +var _ = auth.Config{Stores: postgres.Stores} + +// storesProbe records what the stores of one call answered before and after their release. +type storesProbe struct { + // held is what Held answered before the release. + held bool + // before is the error Held answered before the release. + before error + // after is the error Held answered after the release. + after error +} + +// command returns a command named probe that builds the stores of its call and fills the probe. +func (probe *storesProbe) command() gonsole.Command { + return gonsole.Command{Name: "probe", Summary: "build the stores", Run: probe.run} +} + +// run builds the stores of call, asks Held before and after their release and keeps both answers. +func (probe *storesProbe) run(ctx context.Context, call gonsole.Call) error { + stores, release, err := postgres.Stores(ctx, call) + if err != nil { + return err + } + probe.held, probe.before = stores.Records.Held(ctx) + if err := release(ctx); err != nil { + return err + } + _, probe.after = stores.Records.Held(ctx) + return nil +} + +func TestTheStoresRunOnAPoolOfTheDatabaseSettingThatTheReleaseCloses(t *testing.T) { + t.Parallel() + + db := recorded(t) + var probe storesProbe + p := moduleProgram(db) + p.Commands = append(p.Commands, probe.command()) + + got := testkit.Run(t, p, "", "probe") + + if got.Code != gonsole.ExitDone { + t.Fatalf("probe = %d with %q, want %d", got.Code, got.Stderr, gonsole.ExitDone) + } + if !probe.held || probe.before != nil { + t.Errorf("Held() before the release = %t, %v, want the records table found", probe.held, probe.before) + } + wantUnder(t, probe.after, "check the records") + db.noSessionLeft() +} + +func TestTheStoresPassAMissingDatabaseSettingThroughUnchanged(t *testing.T) { + t.Parallel() + + _, want := gonsole.Call{}.DatabaseURL() + if want == nil { + t.Fatal("gonsole.Call{}.DatabaseURL() error = nil, want the failure of a call with no database setting") + } + + _, release, err := postgres.Stores(t.Context(), gonsole.Call{}) + + if fmt.Sprint(err) != want.Error() || release != nil { + t.Errorf("Stores() = a release %t, %v, want no release and %v as it is", release != nil, err, want) + } +} + +func TestTheStoresNameAnUnreadableAddressWithoutItsPassword(t *testing.T) { + t.Parallel() + + db := testDB{t: t, address: "postgres://u:" + newSecret + "/tail@localhost:5434/app"} + + got := testkit.Run(t, moduleProgram(db), "", "account:list") + + if !strings.HasPrefix(got.Stderr, "myapp: postgres: open the database: ") || strings.Contains(got.Stderr, newSecret) { + t.Errorf("account:list = %d with %q, want the database named and no part of the password", got.Code, got.Stderr) + } +} + +func TestTheStoresNameAnAddressThePoolRefuses(t *testing.T) { + t.Parallel() + + db := migrated(t).withQuery("pool_max_conns", "0") + + got := testkit.Run(t, moduleProgram(db), "", "account:list") + + if got.Code != gonsole.ExitFailed || !strings.HasPrefix(got.Stderr, "myapp: postgres: open the database: ") { + t.Errorf("account:list = %d with %q, want %d naming the database", got.Code, got.Stderr, gonsole.ExitFailed) + } +} From 45531cd6df7b233b17b86f2848b8a00354f05d36 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 20:15:14 +0200 Subject: [PATCH 07/13] test(gonsole/auth/postgres): end a record at its timeout --- gonsole/auth/postgres/record_test.go | 79 ++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 gonsole/auth/postgres/record_test.go diff --git a/gonsole/auth/postgres/record_test.go b/gonsole/auth/postgres/record_test.go new file mode 100644 index 0000000..707e3d1 --- /dev/null +++ b/gonsole/auth/postgres/record_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +package postgres_test + +import ( + "context" + "fmt" + "io" + "strings" + "testing" + "time" + + authkitpostgres "github.com/gopherium/gouncer/authkit/postgres" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/testkit" +) + +// recordBound is how long a record behind a held table may take before the test gives up on its timeout. +const recordBound = 5 * time.Second + +// seeded returns a fresh database holding the records table and one enabled account per role of vocabulary. +func seeded(t *testing.T) testDB { + t.Helper() + db := recorded(t) + pool := db.pool() + defer pool.Close() + var accounts []auth.Account + for _, role := range vocabulary.Known { + accounts = append(accounts, auth.Account{ + Email: role + "@example.com", Name: role + " account", Password: demoPassword, Role: role, + }) + } + if err := auth.EnsureAccounts(t.Context(), authkitpostgres.NewUserStore(pool), accounts, io.Discard); err != nil { + t.Fatalf("seeding the accounts: %v", err) + } + return db +} + +// purge returns a guarded command named report:purge that writes nothing and prints done. +func purge() gonsole.Command { + return gonsole.Command{ + Name: "report:purge", Summary: "do nothing", Capability: "manage_users", + Run: func(_ context.Context, call gonsole.Call) error { + _, err := fmt.Fprintln(call.Stdout, "done") + return err + }, + } +} + +func TestRecordEndsAtItsTimeout(t *testing.T) { + t.Parallel() + + db := seeded(t) + holder := db.connect() + if _, err := holder.Exec(t.Context(), "BEGIN; LOCK TABLE gonsole.records IN ACCESS EXCLUSIVE MODE"); err != nil { + t.Fatalf("locking the records: %v", err) + } + p := moduleProgram(db) + p.Env.Getenv = testkit.Getenv(map[string]string{ + "MYAPP_DATABASE_URL": db.address, "MYAPP_COMMAND_RECORD_TIMEOUT": "200ms", + }) + p.Commands = append(p.Commands, purge()) + ran := make(chan testkit.Result, 1) + + go func() { ran <- testkit.Run(t, p, "", "report:purge", "-as", "admin@example.com") }() + + select { + case got := <-ran: + if got.Code != gonsole.ExitFailed || !strings.HasPrefix(got.Stderr, "myapp: record report:purge: ") { + t.Errorf("report:purge = %d with %q, want %d naming the record", got.Code, got.Stderr, gonsole.ExitFailed) + } + case <-time.After(recordBound): + _ = holder.Close(context.Background()) + <-ran + t.Fatalf("the record outlived its timeout of 200ms by %v, want it to end at the timeout", recordBound) + } +} From 0d49bd149784c4f908a7ef9e04ff4029551b3df8 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Mon, 12 Oct 2026 20:15:15 +0200 Subject: [PATCH 08/13] test(gonsole/auth/postgres): answer every account command as gonsole/auth does with no stores builder --- gonsole/auth/postgres/parity_test.go | 68 ++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 gonsole/auth/postgres/parity_test.go 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, "