From defe96ebd2f32b75d375d0122fa40c94a4aed11b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 13 Oct 2026 14:35:46 +0200 Subject: [PATCH 01/12] test(gonsole/auth): run the core tests on in-memory stores --- gonsole/auth/.golangci.yml | 1 + gonsole/auth/authorize_test.go | 64 ++++---- gonsole/auth/builder_test.go | 11 +- gonsole/auth/createadmin_test.go | 52 +++--- gonsole/auth/db_test.go | 104 ------------ gonsole/auth/go.mod | 1 - gonsole/auth/go.sum | 6 - gonsole/auth/grantrole_test.go | 58 +++++-- gonsole/auth/helpers_test.go | 19 ++- gonsole/auth/list_test.go | 31 ++-- gonsole/auth/memory_test.go | 271 +++++++++++++++++++++++++++++++ gonsole/auth/reach_test.go | 137 +++++++--------- gonsole/auth/record_test.go | 158 +++++------------- gonsole/auth/records_test.go | 91 +++++------ gonsole/auth/role_test.go | 4 +- gonsole/auth/schema_test.go | 226 -------------------------- gonsole/auth/standing_test.go | 4 +- gonsole/auth/stores_test.go | 58 ++----- 18 files changed, 560 insertions(+), 736 deletions(-) delete mode 100644 gonsole/auth/db_test.go create mode 100644 gonsole/auth/memory_test.go delete mode 100644 gonsole/auth/schema_test.go diff --git a/gonsole/auth/.golangci.yml b/gonsole/auth/.golangci.yml index 1061846..06cbae0 100644 --- a/gonsole/auth/.golangci.yml +++ b/gonsole/auth/.golangci.yml @@ -35,6 +35,7 @@ linters: - github.com/gopherium/framework/gonsole - github.com/gopherium/gouncer$ - github.com/gopherium/gouncer/authkit$ + - github.com/gopherium/gouncer/authkit/testkit$ - github.com/gopherium/gouncer/authkit/postgres - github.com/jackc/pgx/v5 - github.com/google/uuid diff --git a/gonsole/auth/authorize_test.go b/gonsole/auth/authorize_test.go index c705786..c76b583 100644 --- a/gonsole/auth/authorize_test.go +++ b/gonsole/auth/authorize_test.go @@ -27,7 +27,7 @@ func checked() auth.Config { return cfg } -// authorizing returns a program over the database at address that checks and records its guarded commands with cfg. +// authorizing returns a program over the world at address that checks and records its guarded commands with cfg. func authorizing( address string, cfg auth.Config, settings map[string]string, commands ...gonsole.Command, ) gonsole.Program { @@ -44,8 +44,11 @@ func TestAuthorizeLetsAnAccountWhoseRoleCarriesTheCapability(t *testing.T) { got := testkit.Run(t, p, "", "report:purge", "-as", " Admin@Example.COM ") - if got.Code != gonsole.ExitDone || got.Stdout != "done\n" || len(records(t, address)) != 1 { - t.Errorf("code %d, stdout %q, stderr %q, want 0, done and one record", got.Code, got.Stdout, got.Stderr) + if got.Code != gonsole.ExitDone || got.Stdout != "done\n" { + t.Errorf("code %d, stdout %q, stderr %q, want 0 and done", got.Code, got.Stdout, got.Stderr) + } + if held := records(t, address); len(held) != 1 { + t.Errorf("records = %v, want one", held) } } @@ -54,7 +57,7 @@ func TestAuthorizeRefusesAnActorItCannotLetThrough(t *testing.T) { tests := []struct { name string - setup []string + setup []change actor string want string }{ @@ -62,11 +65,12 @@ func TestAuthorizeRefusesAnActorItCannotLetThrough(t *testing.T) { "myapp: the account editor@example.com holds the role editor, which lacks manage_users\n"}, {"an address no account holds", nil, "nobody@example.com", "myapp: no account answers to nobody@example.com\n"}, - {"a disabled account", []string{"UPDATE auth.users SET disabled = true WHERE email = 'admin@example.com'"}, + {"a disabled account", []change{disabling("admin@example.com")}, "admin@example.com", "myapp: the account admin@example.com is disabled\n"}, - {"an account never activated", []string{"UPDATE auth.users SET confirmed = false WHERE email = 'admin@example.com'"}, - "admin@example.com", "myapp: the account admin@example.com was never activated\n"}, - {"an account holding no role", []string{"UPDATE auth.users SET role = '' WHERE email = 'author@example.com'"}, + {"an account never activated", []change{inviting("invited@example.com"), + holding("invited@example.com", "admin")}, + "invited@example.com", "myapp: the account invited@example.com was never activated\n"}, + {"an account holding no role", []change{holding("author@example.com", "")}, "author@example.com", "myapp: the account author@example.com holds no role, so it lacks manage_users\n"}, } for _, tt := range tests { @@ -79,7 +83,7 @@ func TestAuthorizeRefusesAnActorItCannotLetThrough(t *testing.T) { got := testkit.Run(t, p, "", "report:purge", "-as", tt.actor) - if got.Code != gonsole.ExitFailed || got.Stdout != "" || got.Stderr != tt.want { + if want := (testkit.Result{Code: gonsole.ExitFailed, Stderr: tt.want}); got != want { t.Errorf("code %d, stdout %q, stderr %q, want 1, nothing run and %q", got.Code, got.Stdout, got.Stderr, tt.want) } if held := records(t, address); len(held) != 0 { @@ -105,7 +109,7 @@ func TestAuthorizeChecksADryRunToo(t *testing.T) { func TestAuthorizeNeverRunsForABlankActor(t *testing.T) { t.Parallel() - p := authorizing("postgres://postgres@127.0.0.1:1/none?connect_timeout=1", checked(), nil, command("report:purge")) + p := authorizing(unreachable, checked(), nil, command("report:purge")) got := testkit.Run(t, p, "", "report:purge", "-as", " ") @@ -124,7 +128,7 @@ func TestAuthorizeStopsAMalformedRecordTimeoutBeforeTheCommandRuns(t *testing.T) got := testkit.Run(t, p, "", "report:purge", "-as", "admin@example.com") want := `myapp: MYAPP_COMMAND_RECORD_TIMEOUT: must be a duration like 30s, got "soon"` + "\n" - if got.Code != gonsole.ExitFailed || got.Stdout != "" || got.Stderr != want { + if got != (testkit.Result{Code: gonsole.ExitFailed, Stderr: want}) { t.Errorf("code %d, stdout %q, stderr %q, want 1, nothing run and %q", got.Code, got.Stdout, got.Stderr, want) } } @@ -138,7 +142,7 @@ func TestAuthorizeNamesTheMigrateCommandWhenTheRecordsAreMissing(t *testing.T) { got := testkit.Run(t, p, "", "report:purge", "-as", "admin@example.com") want := "myapp: the command records are missing, run migrate first\n" - if got.Code != gonsole.ExitFailed || got.Stdout != "" || got.Stderr != want { + if got != (testkit.Result{Code: gonsole.ExitFailed, Stderr: want}) { t.Errorf("code %d, stdout %q, stderr %q, want 1, nothing run and %q", got.Code, got.Stdout, got.Stderr, want) } } @@ -160,32 +164,30 @@ func TestAuthorizePassesARolesFailureThrough(t *testing.T) { } } +// refusesPurge runs report:purge as the admin on the world at address and fails the test unless it answers failure. +func refusesPurge(t *testing.T, address string, failure error) { + t.Helper() + got := testkit.Run(t, authorizing(address, checked(), nil, command("report:purge")), "", "report:purge", "-as", + "admin@example.com") + if want := failed(failure); got != want { + t.Errorf("Run() = %+v, want %+v with nothing run", got, want) + } +} + func TestAuthorizePassesALookupFailureThrough(t *testing.T) { t.Parallel() - address := empty(t) - if err := auth.RecordMigration().Run(t.Context(), address); err != nil { - t.Fatalf("applying the records schema: %v", err) - } - p := authorizing(address, checked(), nil, command("report:purge")) - - got := testkit.Run(t, p, "", "report:purge", "-as", "admin@example.com") + address := recorded(t) + storeAt(t, address).LookupErr = errLookup - if got.Code != gonsole.ExitFailed || got.Stdout != "" || !strings.Contains(got.Stderr, "auth.users") { - t.Errorf("code %d, stdout %q, stderr %q, want 1, nothing run and the missing accounts", - got.Code, got.Stdout, got.Stderr) - } + refusesPurge(t, address, errLookup) } -func TestAuthorizeNamesADatabaseItCannotReach(t *testing.T) { +func TestAuthorizePassesAFailedPresenceCheckThrough(t *testing.T) { t.Parallel() - p := authorizing("postgres://postgres@127.0.0.1:1/none?connect_timeout=1", checked(), nil, command("report:purge")) - - got := testkit.Run(t, p, "", "report:purge", "-as", "admin@example.com") + address := recorded(t) + worldAt(t, address).records.heldErr = errHeld - if got.Code != gonsole.ExitFailed || got.Stdout != "" || - !strings.HasPrefix(got.Stderr, "myapp: failed to connect to ") { - t.Errorf("code %d, stdout %q, stderr %q, want 1 and nothing run", got.Code, got.Stdout, got.Stderr) - } + refusesPurge(t, address, errHeld) } diff --git a/gonsole/auth/builder_test.go b/gonsole/auth/builder_test.go index 7b5831f..5311389 100644 --- a/gonsole/auth/builder_test.go +++ b/gonsole/auth/builder_test.go @@ -12,15 +12,13 @@ import ( "testing" "time" - "github.com/gopherium/gouncer/authkit/postgres" - "github.com/gopherium/framework/gonsole" "github.com/gopherium/framework/gonsole/auth" "github.com/gopherium/framework/gonsole/testkit" ) -// unreachable is a database address nothing answers at, so a run that reads the database setting fails. -const unreachable = "postgres://postgres@127.0.0.1:1/none?connect_timeout=1" +// unreachable is a database address no world answers at, so a run that builds its stores from the setting fails. +const unreachable = "memory://unreachable" // The errors a failing builder, record store or release answers. var ( @@ -78,13 +76,12 @@ func TestEveryCallerRunsOnTheBuiltStores(t *testing.T) { t.Parallel() address := recorded(t) - pool := poolAt(t, address) + held := worldAt(t, address) var builds, releases atomic.Int32 cfg := limited(50) cfg.Stores = func(context.Context, gonsole.Call) (auth.Stores, func(context.Context) error, error) { builds.Add(1) - stores := auth.Stores{Accounts: postgres.NewUserStore(pool), Records: auth.PostgresRecords(pool)} - return stores, func(context.Context) error { + return held.stores(), func(context.Context) error { releases.Add(1) return nil }, nil diff --git a/gonsole/auth/createadmin_test.go b/gonsole/auth/createadmin_test.go index 64cfb3b..6c3d81c 100644 --- a/gonsole/auth/createadmin_test.go +++ b/gonsole/auth/createadmin_test.go @@ -5,6 +5,7 @@ package auth_test import ( "context" "errors" + "fmt" "slices" "testing" @@ -31,9 +32,6 @@ Flags: // migratedLine is the line the account schema step writes before a command that asks for it. const migratedLine = "migrated accounts\n" -// schemaHeld reports whether the database holds the schema its one argument names. -const schemaHeld = "SELECT EXISTS (SELECT FROM pg_namespace WHERE nspname = $1)" - // creating returns the arguments that create maria.perez@example.com under role. func creating(role string) []string { return []string{"account:create-admin", "-email", "maria.perez@example.com", "-name", "Maria Perez", "-role", role} @@ -42,7 +40,7 @@ func creating(role string) []string { func TestCreateAdminCreatesAnAccountAfterTheSchema(t *testing.T) { t.Parallel() - address := empty(t) + address := fresh(t) var reads rolesRead got := testkit.Run(t, program(address, auth.CreateAdmin(config(&reads, ""))), demoPassword+"\n", creating("admin")...) @@ -52,6 +50,9 @@ func TestCreateAdminCreatesAnAccountAfterTheSchema(t *testing.T) { if got != want { t.Fatalf("Run() = %+v, want %+v", got, want) } + if ran := worldAt(t, address).migrations.Load(); ran != 1 { + t.Errorf("the accounts step ran %d times, want once before the account", ran) + } if held := account(t, storeAt(t, address), "maria.perez@example.com"); held.Role != "admin" || held.Name != "Maria Perez" { t.Errorf("account = %+v, want Maria Perez under admin", held) @@ -123,7 +124,7 @@ func TestCreateAdminRefusesAMissingFlagBeforeAnySchemaStep(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - address := empty(t) + address := fresh(t) var reads rolesRead got := testkit.Run(t, program(address, auth.CreateAdmin(config(&reads, ""))), "", tc.args...) @@ -134,8 +135,8 @@ func TestCreateAdminRefusesAMissingFlagBeforeAnySchemaStep(t *testing.T) { if reads.count != 0 { t.Errorf("roles read %d times, want none", reads.count) } - if found(t, address, schemaHeld, "auth") { - t.Error("the account schema exists, want a bare database") + if ran := worldAt(t, address).migrations.Load(); ran != 0 { + t.Errorf("the accounts step ran %d times, want a bare database", ran) } }) } @@ -147,7 +148,7 @@ func TestCreateAdminTakesARoleThePluginsDeclare(t *testing.T) { address := migrated(t) cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { return auth.Roles{Known: append(slices.Clone(vocabulary.Known), "superadmin"), Privileged: vocabulary.Privileged}, nil - }} + }, Stores: memoryStores} got := testkit.Run(t, program(address, auth.CreateAdmin(cfg)), demoPassword+"\n", creating("superadmin")...) @@ -164,7 +165,7 @@ func TestCreateAdminFailsWhenTheRolesCannotBeRead(t *testing.T) { cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { return auth.Roles{}, errors.New("the plugin table is locked") - }} + }, Stores: memoryStores} got := testkit.Run(t, program(migrated(t), auth.CreateAdmin(cfg)), demoPassword+"\n", creating("admin")...) @@ -174,30 +175,35 @@ func TestCreateAdminFailsWhenTheRolesCannotBeRead(t *testing.T) { } } -func TestCreateAdminNamesNoRoleWhenTheProgramDeclaresNone(t *testing.T) { - t.Parallel() +// unknownRole is one role account:create-admin refuses and the alternatives its refusal names. +type unknownRole struct { + role, alternatives string +} - cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { return auth.Roles{}, nil }} +// refusesUnknownRole runs account:create-admin under refused.role over a program declaring known and wants it refused. +func refusesUnknownRole(t *testing.T, known []string, refused unknownRole) { + t.Helper() + cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { + return auth.Roles{Known: known}, nil + }, Stores: memoryStores} - got := testkit.Run(t, program(migrated(t), auth.CreateAdmin(cfg)), "", creating("admin")...) + got := testkit.Run(t, program(migrated(t), auth.CreateAdmin(cfg)), "", creating(refused.role)...) - want := migratedLine + "myapp: unknown role \"admin\", want a role the program declares\n\n" + createPage + want := migratedLine + fmt.Sprintf("myapp: unknown role %q, want %s\n\n", refused.role, refused.alternatives) + + createPage if got.Code != gonsole.ExitMisused || got.Stderr != want { t.Errorf("Run() = %+v, want exit 2 and %q", got, want) } } -func TestCreateAdminNamesTheOneRoleTheProgramDeclares(t *testing.T) { +func TestCreateAdminNamesNoRoleWhenTheProgramDeclaresNone(t *testing.T) { t.Parallel() - cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { - return auth.Roles{Known: []string{"admin"}}, nil - }} + refusesUnknownRole(t, nil, unknownRole{"admin", "a role the program declares"}) +} - got := testkit.Run(t, program(migrated(t), auth.CreateAdmin(cfg)), "", creating("owner")...) +func TestCreateAdminNamesTheOneRoleTheProgramDeclares(t *testing.T) { + t.Parallel() - want := migratedLine + "myapp: unknown role \"owner\", want admin\n\n" + createPage - if got.Code != gonsole.ExitMisused || got.Stderr != want { - t.Errorf("Run() = %+v, want exit 2 and %q", got, want) - } + refusesUnknownRole(t, []string{"admin"}, unknownRole{"owner", "admin"}) } diff --git a/gonsole/auth/db_test.go b/gonsole/auth/db_test.go deleted file mode 100644 index 51b32d1..0000000 --- a/gonsole/auth/db_test.go +++ /dev/null @@ -1,104 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package auth_test - -import ( - "context" - "testing" - "time" - - "github.com/gopherium/gouncer/authkit/postgres" - "github.com/gopherium/gouncer/authkit/postgres/testdb" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - _ "github.com/jackc/pgx/v5/stdlib" - "github.com/peterldowns/pgtestdb" - "github.com/pressly/goose/v3/lock" -) - -// migrated returns the address of a fresh database holding the account schema. -func migrated(t *testing.T) string { - t.Helper() - return pgtestdb.Custom(t, testdb.Config(), testdb.Migrator()).URL() -} - -// empty returns the address of a fresh database holding no schema. -func empty(t *testing.T) string { - t.Helper() - return pgtestdb.Custom(t, testdb.Config(), pgtestdb.NoopMigrator{}).URL() -} - -// run executes each statement on the database at address, failing the test at the first that fails. -func run(t *testing.T, address string, statements ...string) { - t.Helper() - conn, err := pgx.Connect(t.Context(), address) - if err != nil { - t.Fatalf("connecting to %s: %v", address, err) - } - defer func() { _ = conn.Close(t.Context()) }() - for _, statement := range statements { - if _, err := conn.Exec(t.Context(), statement); err != nil { - t.Fatalf("%s: %v", statement, err) - } - } -} - -// exists reports whether the database at address holds the table named name. -func exists(t *testing.T, address, name string) bool { - t.Helper() - return found(t, address, "SELECT to_regclass($1) IS NOT NULL", name) -} - -// found reports whether lookup answers true for values on the database at address. -func found(t *testing.T, address, lookup string, values ...any) bool { - t.Helper() - conn, err := pgx.Connect(t.Context(), address) - if err != nil { - t.Fatalf("connecting to %s: %v", address, err) - } - defer func() { _ = conn.Close(t.Context()) }() - var held bool - if err := conn.QueryRow(t.Context(), lookup, values...).Scan(&held); err != nil { - t.Fatalf("looking for %v: %v", values, err) - } - return held -} - -// holdMigrationLock returns a connection holding goose's migration lock on the database at address. -func holdMigrationLock(t *testing.T, address string) *pgx.Conn { - t.Helper() - conn, err := pgx.Connect(t.Context(), address) - if err != nil { - t.Fatalf("connecting to %s: %v", address, err) - } - t.Cleanup(func() { _ = conn.Close(context.Background()) }) - if _, err := conn.Exec(t.Context(), "SELECT pg_advisory_lock($1)", lock.DefaultLockID); err != nil { - t.Fatalf("holding the migration lock: %v", err) - } - return conn -} - -// awaitSession returns once a session other than holder has column like pattern, failing if ran ends first. -func awaitSession(t *testing.T, address string, holder *pgx.Conn, ran <-chan error, column, pattern string) { - t.Helper() - lookup := "SELECT EXISTS (SELECT FROM pg_stat_activity WHERE datname = current_database()" + - " AND pid NOT IN (pg_backend_pid(), $1) AND " + column + " LIKE $2)" - for !found(t, address, lookup, int32(holder.PgConn().PID()), pattern) { - select { - case err := <-ran: - t.Fatalf("Run() = %v before a session had %s like %s", err, column, pattern) - case <-time.After(10 * time.Millisecond): - } - } -} - -// storeAt returns an account store over the database at address, closed when the test ends. -func storeAt(t *testing.T, address string) *postgres.UserStore { - t.Helper() - pool, err := pgxpool.New(t.Context(), address) - if err != nil { - t.Fatalf("opening %s: %v", address, err) - } - t.Cleanup(pool.Close) - return postgres.NewUserStore(pool) -} diff --git a/gonsole/auth/go.mod b/gonsole/auth/go.mod index ce1a80f..04a10fa 100644 --- a/gonsole/auth/go.mod +++ b/gonsole/auth/go.mod @@ -9,7 +9,6 @@ require ( github.com/gopherium/gouncer/authkit v0.19.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 ) diff --git a/gonsole/auth/go.sum b/gonsole/auth/go.sum index b437828..66ad31f 100644 --- a/gonsole/auth/go.sum +++ b/gonsole/auth/go.sum @@ -23,8 +23,6 @@ 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= @@ -35,8 +33,6 @@ github.com/pashagolub/pgxmock/v4 v4.9.0 h1:itlO8nrVRnzkdMBXLs8pWUyyB2PC3Gku0WGIj 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= @@ -54,8 +50,6 @@ 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= diff --git a/gonsole/auth/grantrole_test.go b/gonsole/auth/grantrole_test.go index 4e21c8d..e420a24 100644 --- a/gonsole/auth/grantrole_test.go +++ b/gonsole/auth/grantrole_test.go @@ -5,6 +5,7 @@ package auth_test import ( "context" "errors" + "slices" "strings" "testing" @@ -31,7 +32,7 @@ Flags: // dryRunNotice is the line a dry run of myapp ends with on stderr. const dryRunNotice = "myapp: dry run, nothing changed, pass -yes to apply\n" -// withRoleless returns the address of a seeded database that also holds count accounts under no role. +// withRoleless returns the address of a seeded world that also holds count accounts under no role. func withRoleless(t *testing.T, count int) string { t.Helper() address := seeded(t) @@ -83,10 +84,8 @@ func TestGrantRoleGivesTheRoleOnlyWithYes(t *testing.T) { if got != want { t.Fatalf("Run() = %+v, want %+v", got, want) } - if tc.roleless > 0 { - if held := account(t, storeAt(t, address), "a-roleless@example.com"); held.Role != tc.role { - t.Errorf("role = %q, want %q", held.Role, tc.role) - } + if held, err := storeAt(t, address).UserByEmail(t.Context(), "a-roleless@example.com"); held.Role != tc.role { + t.Errorf("role = %q, %v, want %q", held.Role, err, tc.role) } if held := account(t, storeAt(t, address), "admin@example.com"); held.Role != "admin" { t.Errorf("an account holding a role now holds %q, want admin kept", held.Role) @@ -145,7 +144,7 @@ func TestGrantRoleRefusesAMissingRoleBeforeAnySchemaStep(t *testing.T) { t.Run(condition, func(t *testing.T) { t.Parallel() - address := empty(t) + address := fresh(t) var reads rolesRead got := testkit.Run(t, program(address, auth.GrantRole(config(&reads, ""))), "", args...) @@ -158,8 +157,8 @@ func TestGrantRoleRefusesAMissingRoleBeforeAnySchemaStep(t *testing.T) { if reads.count != 0 { t.Errorf("roles read %d times, want none", reads.count) } - if found(t, address, schemaHeld, "auth") { - t.Error("the account schema exists, want a bare database") + if ran := worldAt(t, address).migrations.Load(); ran != 0 { + t.Errorf("the accounts step ran %d times, want a bare database", ran) } }) } @@ -198,13 +197,15 @@ func TestGrantRoleRefusesAMissingRoleBeforeItChecksTheActingAccount(t *testing.T func TestGrantRoleDryRunFailsOverADatabaseNeverMigrated(t *testing.T) { t.Parallel() + address := fresh(t) + storeAt(t, address).ListUsersErr = errListing var reads rolesRead - got := testkit.Run(t, program(empty(t), auth.GrantRole(config(&reads, ""))), "", "account:grant-role", "-role", + got := testkit.Run(t, program(address, auth.GrantRole(config(&reads, ""))), "", "account:grant-role", "-role", "editor") - if got.Code != gonsole.ExitFailed || !strings.HasPrefix(got.Stderr, "myapp: postgres: list users: ") { - t.Errorf("Run() = %+v, want exit 1 and the failed listing", got) + if want := failed(errListing); got != want { + t.Errorf("Run() = %+v, want %+v, the failed listing", got, want) } } @@ -232,7 +233,7 @@ func TestGrantRoleFailsWhenTheRolesCannotBeRead(t *testing.T) { cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { return auth.Roles{}, errors.New("the plugin table is locked") - }} + }, Stores: memoryStores} got := testkit.Run(t, program(withRoleless(t, 1), auth.GrantRole(cfg)), "", "account:grant-role", "-role", "editor") @@ -245,22 +246,43 @@ func TestGrantRoleFailsWhenTheRolesCannotBeRead(t *testing.T) { func TestGrantRoleReportsAWriteTheStoreRefuses(t *testing.T) { t.Parallel() + address := withRoleless(t, 1) + storeAt(t, address).grantErr = errGrant + cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { + return vocabulary, nil + }, Stores: memoryStores} + + got := testkit.Run(t, program(address, auth.GrantRole(cfg)), "", "account:grant-role", "-role", "editor", "-yes") + + want := failed(errGrant) + want.Stderr = migratedLine + want.Stderr + if got != want { + t.Errorf("Run() = %+v, want %+v: exit 1, nothing granted and the refused write", got, want) + } + if held := account(t, storeAt(t, address), "a-roleless@example.com"); held.Role != "" { + t.Errorf("role = %q, want none granted", held.Role) + } +} + +func TestGrantRoleWritesUnderTheCallsContext(t *testing.T) { + t.Parallel() + address := withRoleless(t, 1) ctx, cancel := context.WithCancel(t.Context()) defer cancel() cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { cancel() return vocabulary, nil - }} + }, Stores: memoryStores} var stdout, stderr strings.Builder code := program(address, auth.GrantRole(cfg)).Run(ctx, []string{"account:grant-role", "-role", "editor", "-yes"}, strings.NewReader(""), &stdout, &stderr) - if code != gonsole.ExitFailed || stdout.String() != "" || - !strings.Contains(stderr.String(), "myapp: postgres: grant role: ") { - t.Errorf("Run() = %d, %q, %q, want exit 1, nothing granted and the refused write", code, stdout.String(), - stderr.String()) + got := testkit.Result{Code: code, Stdout: stdout.String(), Stderr: stderr.String()} + want := testkit.Result{Code: gonsole.ExitFailed, Stderr: migratedLine + "myapp: context canceled\n"} + if got != want { + t.Errorf("Run() = %+v, want %+v: exit 1, nothing granted and the ended context", got, want) } if held := account(t, storeAt(t, address), "a-roleless@example.com"); held.Role != "" { t.Errorf("role = %q, want none granted", held.Role) @@ -284,7 +306,7 @@ func TestGrantRoleAsksTheProgramForItsCapability(t *testing.T) { if missingActor.Code != gonsole.ExitMisused { t.Errorf("Run() without -as = %+v, want exit 2", missingActor) } - if got.Code != gonsole.ExitDone || len(asked) != 1 || asked[0] != "admin@example.com manage_accounts" { + if got.Code != gonsole.ExitDone || !slices.Equal(asked, []string{"admin@example.com manage_accounts"}) { t.Errorf("Run() = %+v, Authorize asked %q, want exit 0 and one manage_accounts check", got, asked) } } diff --git a/gonsole/auth/helpers_test.go b/gonsole/auth/helpers_test.go index 6544560..e95de2e 100644 --- a/gonsole/auth/helpers_test.go +++ b/gonsole/auth/helpers_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/gopherium/gouncer" - "github.com/gopherium/gouncer/authkit/postgres" "github.com/gopherium/framework/gonsole" "github.com/gopherium/framework/gonsole/auth" @@ -25,7 +24,7 @@ type rolesRead struct { count int } -// config returns a config that answers vocabulary, counts each read in reads and names capability. +// config returns a config over the in-memory stores that answers vocabulary, counts reads and names capability. func config(reads *rolesRead, capability string) auth.Config { return auth.Config{ Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { @@ -35,16 +34,17 @@ func config(reads *rolesRead, capability string) auth.Config { return vocabulary, nil }, Capability: capability, + Stores: memoryStores, } } -// program returns a program called myapp over the database at address, offering commands. +// program returns a program called myapp over the world at address, offering commands. func program(address string, commands ...gonsole.Command) 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: []gonsole.Step{auth.Migration()}, + Migrations: []gonsole.Step{accountsStep}, Commands: commands, Authorize: func(context.Context, gonsole.Call, string) error { return nil }, Record: func(context.Context, gonsole.Call, string) error { return nil }, @@ -52,7 +52,7 @@ func program(address string, commands ...gonsole.Command) gonsole.Program { } // account returns the account the store holds at email, failing the test when it holds none. -func account(t *testing.T, store *postgres.UserStore, email string) gouncer.User { +func account(t *testing.T, store *memoryAccounts, email string) gouncer.User { t.Helper() held, err := store.UserByEmail(t.Context(), email) if err != nil { @@ -61,10 +61,10 @@ func account(t *testing.T, store *postgres.UserStore, email string) gouncer.User return held } -// seeded returns the address of a migrated database holding one enabled account per role of vocabulary. +// seeded returns the address of a fresh world holding one enabled account per role of vocabulary. func seeded(t *testing.T) string { t.Helper() - address := migrated(t) + address := fresh(t) var accounts []auth.Account for _, role := range vocabulary.Known { accounts = append(accounts, auth.Account{ @@ -76,3 +76,8 @@ func seeded(t *testing.T) string { } return address } + +// failed returns the result of a run of myapp that exits 1, printing nothing and naming err on stderr. +func failed(err error) testkit.Result { + return testkit.Result{Code: gonsole.ExitFailed, Stderr: "myapp: " + err.Error() + "\n"} +} diff --git a/gonsole/auth/list_test.go b/gonsole/auth/list_test.go index bb75a9f..95cbae9 100644 --- a/gonsole/auth/list_test.go +++ b/gonsole/auth/list_test.go @@ -13,7 +13,7 @@ import ( "github.com/gopherium/framework/gonsole/testkit" ) -// listed returns the address of a seeded database whose editor is disabled and that holds one account under no role. +// listed returns the address of a seeded world whose editor is disabled and that holds one account under no role. func listed(t *testing.T) string { t.Helper() address := seeded(t) @@ -59,11 +59,10 @@ func TestListAnswersOneDocument(t *testing.T) { got := testkit.Run(t, program(address, auth.List(config(&reads, ""))), "", "account:list", "-json") - document := got.Stdout store := storeAt(t, address) for _, email := range []string{"admin@example.com", "author@example.com", "editor@example.com", "roleless@example.com"} { - document = strings.ReplaceAll(document, account(t, store, email).ID.String(), "id of "+email) + got.Stdout = strings.ReplaceAll(got.Stdout, account(t, store, email).ID.String(), "id of "+email) } want := `{ "accounts": [ @@ -98,8 +97,8 @@ func TestListAnswersOneDocument(t *testing.T) { ] } ` - if got.Code != gonsole.ExitDone || got.Stderr != "" || document != want { - t.Errorf("Run() = %+v with the stored ids named, document %q, want %q", got, document, want) + if got != (testkit.Result{Code: gonsole.ExitDone, Stdout: want}) { + t.Errorf("Run() = %+v with the stored ids named, want the document %q", got, want) } } @@ -124,30 +123,24 @@ func TestListFailsWithoutAUsableDatabase(t *testing.T) { t.Parallel() cases := []struct { - name string - env map[string]string - prefix string + name string + failure error }{ - {"no database setting", map[string]string{}, "myapp: MYAPP_DATABASE_URL is required\n"}, - {"a malformed database address", map[string]string{ - "MYAPP_DATABASE_URL": "postgres://localhost:5434/postgres?pool_max_conns=0", - }, "myapp: open the database: "}, - {"a database never migrated", nil, "myapp: postgres: list users: "}, + {"a database never migrated", errListing}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() var reads rolesRead - p := program(empty(t), auth.List(config(&reads, ""))) - if tc.env != nil { - p.Env.Getenv = testkit.Getenv(tc.env) - } + address := fresh(t) + storeAt(t, address).ListUsersErr = tc.failure + p := program(address, auth.List(config(&reads, ""))) got := testkit.Run(t, p, "", "account:list") - if got.Code != gonsole.ExitFailed || got.Stdout != "" || !strings.HasPrefix(got.Stderr, tc.prefix) { - t.Errorf("Run() = %+v, want exit 1 and stderr opening with %q", got, tc.prefix) + if want := failed(tc.failure); got != want { + t.Errorf("Run() = %+v, want %+v", got, want) } }) } diff --git a/gonsole/auth/memory_test.go b/gonsole/auth/memory_test.go new file mode 100644 index 0000000..69d44f8 --- /dev/null +++ b/gonsole/auth/memory_test.go @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "cmp" + "context" + "errors" + "fmt" + "maps" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gopherium/gouncer" + "github.com/gopherium/gouncer/authkit/testkit" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" +) + +// The errors a world's stores answer when a test makes them fail. +var ( + errLookup = errors.New("look up the account: the accounts are missing") + errListing = errors.New("list users: the accounts are missing") + errGrant = errors.New("grant role: the store refused the write") + errHeld = errors.New("look for the records: the store is out of reach") + errRead = errors.New("read the records: the flags are missing") +) + +// world is one in-memory database: an account store, a records fake and how often the accounts step ran. +type world struct { + accounts *memoryAccounts + records *memoryRecords + migrations atomic.Int32 +} + +// stores returns the world's account store and records. +func (w *world) stores() auth.Stores { + return auth.Stores{Accounts: w.accounts, Records: w.records} +} + +// memoryAccounts is gouncer's in-memory account store, whose grant answers grantErr when it is set. +type memoryAccounts struct { + *testkit.Store + grantErr error +} + +// GrantRoleToRoleless answers the end of ctx or grantErr first, the grant of the store otherwise. +func (a *memoryAccounts) GrantRoleToRoleless(ctx context.Context, role string) (int64, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + if a.grantErr != nil { + return 0, a.grantErr + } + return a.Store.GrantRoleToRoleless(ctx, role) +} + +// memoryRecords is a record store in memory, finding the account of each entry in its world's account store. +type memoryRecords struct { + mu sync.Mutex + accounts *memoryAccounts + held bool + rows []auth.Entry + heldErr error + latestErr error +} + +// Held reports whether the records step ran, answering heldErr when it is set. +func (r *memoryRecords) Held(context.Context) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.held, r.heldErr +} + +// Insert stores entry as applied now. +func (r *memoryRecords) Insert(ctx context.Context, entry auth.Entry) error { + return r.plant(ctx, entry, time.Now().UTC()) +} + +// Latest lists at most limit entries, the newest first and the higher id first among entries applied at once. +func (r *memoryRecords) Latest(_ context.Context, limit int) ([]auth.Entry, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.latestErr != nil { + return nil, r.latestErr + } + listed := slices.SortedFunc(slices.Values(r.rows), newestFirst) + return append([]auth.Entry{}, listed[:min(max(limit, 0), len(listed))]...), nil +} + +// plant stores entry as applied at appliedAt, under the account its actor names and with lists that are never nil. +func (r *memoryRecords) plant(ctx context.Context, entry auth.Entry, appliedAt time.Time) error { + account, err := r.accountOf(ctx, entry.Actor) + if err != nil { + return err + } + flags := map[string]string{} + maps.Copy(flags, entry.Flags) + entry.AppliedAt, entry.AccountID = appliedAt, account + entry.Args, entry.Flags = append([]string{}, entry.Args...), flags + r.mu.Lock() + defer r.mu.Unlock() + r.rows = append(r.rows, entry) + return nil +} + +// accountOf returns the id of the account at actor, nil when no account answers to it. +func (r *memoryRecords) accountOf(ctx context.Context, actor string) (*string, error) { + user, err := r.accounts.UserByEmail(ctx, actor) + if errors.Is(err, gouncer.ErrUserNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + id := user.ID.String() + return &id, nil +} + +// hold makes the records held, as their step does. +func (r *memoryRecords) hold() { + r.mu.Lock() + defer r.mu.Unlock() + r.held = true +} + +// all returns every entry, the oldest first. +func (r *memoryRecords) all() []auth.Entry { + r.mu.Lock() + defer r.mu.Unlock() + return slices.SortedStableFunc(slices.Values(r.rows), func(a, b auth.Entry) int { + return a.AppliedAt.Compare(b.AppliedAt) + }) +} + +// newestFirst orders a before b when a was applied later, or at once under a higher id. +func newestFirst(a, b auth.Entry) int { + return cmp.Or(b.AppliedAt.Compare(a.AppliedAt), strings.Compare(b.ID, a.ID)) +} + +// worlds holds every live world by its address. +var worlds sync.Map + +// addresses counts the worlds made so far, so each takes an address of its own. +var addresses atomic.Int64 + +// fresh registers an empty world under an address of its own, dropped when the test ends, and returns the address. +func fresh(t *testing.T) string { + t.Helper() + address := fmt.Sprintf("memory://%d", addresses.Add(1)) + accounts := &memoryAccounts{Store: testkit.NewStore()} + worlds.Store(address, &world{accounts: accounts, records: &memoryRecords{accounts: accounts}}) + t.Cleanup(func() { worlds.Delete(address) }) + return address +} + +// lookup returns the world at address, an error naming an address no world holds. +func lookup(address string) (*world, error) { + held, ok := worlds.Load(address) + if !ok { + return nil, fmt.Errorf("no world at %s", address) + } + return held.(*world), nil +} + +// worldAt returns the world at address, failing the test when no world is there. +func worldAt(t *testing.T, address string) *world { + t.Helper() + held, err := lookup(address) + if err != nil { + t.Fatal(err) + } + return held +} + +// storeAt returns the account store of the world at address. +func storeAt(t *testing.T, address string) *memoryAccounts { + t.Helper() + return worldAt(t, address).accounts +} + +// migrated returns the address of a fresh world the accounts step ran on. +func migrated(t *testing.T) string { + t.Helper() + address := fresh(t) + if err := accountsStep.Run(t.Context(), address); err != nil { + t.Fatalf("applying the account schema: %v", err) + } + return address +} + +// memoryStores builds the stores of the world at the call's database setting, with nothing to release. +func memoryStores(_ context.Context, call gonsole.Call) (auth.Stores, func(context.Context) error, error) { + address, err := call.DatabaseURL() + if err != nil { + return auth.Stores{}, nil, err + } + held, err := lookup(address) + if err != nil { + return auth.Stores{}, nil, err + } + return held.stores(), nil, nil +} + +// step returns the step called name that applies apply to the world at the address it runs against. +func step(name string, apply func(w *world)) gonsole.Step { + return gonsole.Step{Name: name, Run: func(_ context.Context, address string) error { + held, err := lookup(address) + if err == nil { + apply(held) + } + return err + }} +} + +// accountsStep is the accounts step, counting its runs in the world at its address. +var accountsStep = step("accounts", func(w *world) { w.migrations.Add(1) }) + +// recordsStep is the records step, which makes the records of the world at its address held. +var recordsStep = step("records", func(w *world) { w.records.hold() }) + +// change is one write a test makes to an account store before its run. +type change func(t *testing.T, store *memoryAccounts) + +// run applies each change to the account store of the world at address. +func run(t *testing.T, address string, changes ...change) { + t.Helper() + store := storeAt(t, address) + for _, apply := range changes { + apply(t, store) + } +} + +// holding returns the change that puts the account at email under role. +func holding(email, role string) change { + return func(t *testing.T, store *memoryAccounts) { + t.Helper() + if err := store.SetUserRole(t.Context(), account(t, store, email).ID, role, nil); err != nil { + t.Fatalf("SetUserRole(%s, %q) = %v", email, role, err) + } + } +} + +// disabling returns the change that disables the account at email. +func disabling(email string) change { + return func(t *testing.T, store *memoryAccounts) { + t.Helper() + if err := store.SetUserDisabled(t.Context(), account(t, store, email).ID, true); err != nil { + t.Fatalf("SetUserDisabled(%s) = %v", email, err) + } + } +} + +// inviting returns the change that adds an account at email that was invited and never activated. +func inviting(email string) change { + return func(t *testing.T, store *memoryAccounts) { + t.Helper() + invited, err := gouncer.NewInvitedUser(email, "Maria Perez") + if err != nil { + t.Fatalf("NewInvitedUser() = %v", err) + } + if err := store.CreateUser(t.Context(), invited); err != nil { + t.Fatalf("CreateUser() = %v", err) + } + } +} diff --git a/gonsole/auth/reach_test.go b/gonsole/auth/reach_test.go index 783e24f..150fed5 100644 --- a/gonsole/auth/reach_test.go +++ b/gonsole/auth/reach_test.go @@ -34,7 +34,7 @@ func reaching() auth.Config { return cfg } -// managed returns the address of a recorded database that also holds manager@example.com under manager. +// managed returns the address of a recorded world that also holds manager@example.com under manager. func managed(t *testing.T) string { t.Helper() address := recorded(t) @@ -45,27 +45,17 @@ func managed(t *testing.T) string { return address } -// overseen returns a program over the database at address whose account commands auth.Authorize checks. +// overseen returns a program over the world at address whose account commands auth.Authorize checks. func overseen(address string) gonsole.Program { return authorizing(address, reaching(), nil, auth.Commands(reaching())...) } -// trusting returns a program over the database at address whose own check lets every acting account through. +// trusting returns a program over the world at address whose own check lets every acting account through. func trusting(address string) gonsole.Program { return recording(address, reaching(), nil, auth.Commands(reaching())...) } -// holding returns the statement that puts the account at email under role. -func holding(email, role string) string { - return fmt.Sprintf("UPDATE auth.users SET role = '%s' WHERE email = '%s'", role, email) -} - -// disabling returns the statement that disables the account at email. -func disabling(email string) string { - return fmt.Sprintf("UPDATE auth.users SET disabled = true WHERE email = '%s'", email) -} - -// standingOf returns the role and the standing of the account at email in the database at address. +// standingOf returns the role and the standing of the account at email in the world at address. func standingOf(t *testing.T, address, email string) string { t.Helper() held := account(t, storeAt(t, address), email) @@ -94,7 +84,7 @@ func TestAccountCommandsApplyAChangeWithinTheReachOfTheActingAccount(t *testing. tests := []struct { name string - setup []string + setup []change over func(address string) gonsole.Program actor string args []string @@ -104,25 +94,25 @@ func TestAccountCommandsApplyAChangeWithinTheReachOfTheActingAccount(t *testing. []string{"account:role", "author@example.com", "manager"}, "set author@example.com to manager\n"}, {"the manager disables the author", nil, overseen, "manager@example.com", []string{"account:disable", "author@example.com"}, "disabled author@example.com\n"}, - {"the manager enables a disabled author", []string{disabling("author@example.com")}, overseen, + {"the manager enables a disabled author", []change{disabling("author@example.com")}, overseen, "manager@example.com", []string{"account:enable", "author@example.com"}, "enabled author@example.com\n"}, - {"the manager grants author", []string{holding("editor@example.com", "")}, overseen, "manager@example.com", + {"the manager grants author", []change{holding("editor@example.com", "")}, overseen, "manager@example.com", []string{"account:grant-role", "-role", "author"}, "granted author to 1 account\n"}, - {"the manager changes an account with no role", []string{holding("editor@example.com", "")}, overseen, + {"the manager changes an account with no role", []change{holding("editor@example.com", "")}, overseen, "manager@example.com", []string{"account:role", "editor@example.com", "author"}, "set editor@example.com to author\n"}, {"the manager changes an account whose stored role the roles leave out", - []string{holding("editor@example.com", "guest")}, overseen, "manager@example.com", + []change{holding("editor@example.com", "guest")}, overseen, "manager@example.com", []string{"account:disable", "editor@example.com"}, "disabled editor@example.com\n"}, - {"the manager disables a second manager", []string{holding("author@example.com", "manager")}, overseen, + {"the manager disables a second manager", []change{holding("author@example.com", "manager")}, overseen, "manager@example.com", []string{"account:disable", "author@example.com"}, "disabled author@example.com\n"}, {"the admin sets the editor to admin", nil, overseen, "admin@example.com", []string{"account:role", "editor@example.com", "admin"}, "set editor@example.com to admin\n"}, - {"the admin demotes a second admin", []string{holding("editor@example.com", "admin")}, overseen, + {"the admin demotes a second admin", []change{holding("editor@example.com", "admin")}, overseen, "admin@example.com", []string{"account:role", "editor@example.com", "author"}, "set editor@example.com to author\n"}, {"an actor with no role gives itself author under the program's own check", - []string{holding("manager@example.com", "")}, trusting, "manager@example.com", + []change{holding("manager@example.com", "")}, trusting, "manager@example.com", []string{"account:grant-role", "-role", "author"}, "granted author to 1 account\n"}, } for _, tt := range tests { @@ -160,7 +150,7 @@ func TestAccountCommandsRefuseAChangeBeyondTheReachOfTheActingAccount(t *testing tests := []struct { name string - setup []string + setup []change args []string target string migrated string @@ -179,10 +169,10 @@ func TestAccountCommandsRefuseAChangeBeyondTheReachOfTheActingAccount(t *testing {"a disable of an account under a role carrying more", nil, []string{"account:disable", "admin@example.com"}, "admin@example.com", "", "the role admin of admin@example.com carries change_others_work, which the account manager@example.com lacks"}, - {"an enable of an account under a role carrying more", []string{disabling("editor@example.com")}, + {"an enable of an account under a role carrying more", []change{disabling("editor@example.com")}, []string{"account:enable", "editor@example.com"}, "editor@example.com", "", "the role editor of editor@example.com carries change_others_work, which the account manager@example.com lacks"}, - {"a grant of a role carrying more", []string{holding("author@example.com", "")}, + {"a grant of a role carrying more", []change{holding("author@example.com", "")}, []string{"account:grant-role", "-role", "editor"}, "author@example.com", migratedLine, "the role editor carries change_others_work, which the account manager@example.com lacks"}, } @@ -251,7 +241,7 @@ func TestAccountCommandsNameAnActingAddressNoAccountHolds(t *testing.T) { func TestAccountCommandsTreatABlankActingAddressAsAMisuse(t *testing.T) { t.Parallel() - p := trusting("postgres://postgres@127.0.0.1:1/none?connect_timeout=1") + p := trusting(unreachable) for _, line := range reachingLines { t.Run(line[0], func(t *testing.T) { t.Parallel() @@ -279,77 +269,68 @@ func TestAccountCommandsRefuseAnActingAccountWithoutARoleAnythingThatCarriesACap "which the account manager@example.com lacks\n", "account:disable", "admin@example.com", "-as", "manager@example.com") } -func TestDisableRefusesTheActingAccountDisablingItself(t *testing.T) { - t.Parallel() +// selfChange is one run in which the admin changes its own account. +type selfChange struct { + name string + setup []change + args []string +} - tests := []struct { - name string - setup []string - args []string - }{ - {"a dry run beside a second admin", []string{holding("editor@example.com", "admin")}, - []string{"account:disable", "admin@example.com"}}, - {"an applied run beside a second admin", []string{holding("editor@example.com", "admin")}, - []string{"account:disable", "admin@example.com", "-yes"}}, - {"an address typed in capitals between spaces", []string{holding("editor@example.com", "admin")}, - []string{"account:disable", " Admin@Example.COM ", "-yes"}}, - {"a dry run of the single admin", nil, []string{"account:disable", "admin@example.com"}}, - {"an applied run of the single admin", nil, []string{"account:disable", "admin@example.com", "-yes"}}, - } - for _, tt := range tests { +// refusesSelf runs each change as the admin on a recorded world, wanting message, no record and the admin kept. +func refusesSelf(t *testing.T, message string, changes []selfChange) { + t.Helper() + for _, tt := range changes { t.Run(tt.name, func(t *testing.T) { t.Parallel() address := recorded(t) run(t, address, tt.setup...) - refuses(t, overseen(address), "myapp: the account admin@example.com cannot disable itself\n", - slices.Concat(tt.args, []string{"-as", "admin@example.com"})...) + refuses(t, overseen(address), message, slices.Concat(tt.args, []string{"-as", "admin@example.com"})...) if held := records(t, address); len(held) != 0 { t.Errorf("records = %v, want none", held) } if after := standingOf(t, address, "admin@example.com"); after != `role "admin", disabled false` { - t.Errorf("admin@example.com holds %s, want it kept enabled", after) + t.Errorf("admin@example.com holds %s, want it kept an enabled admin", after) } }) } } -func TestSetRoleRefusesTheActingAccountChangingItsOwnRole(t *testing.T) { - t.Parallel() +// disablingItself are the runs in which the admin disables its own account. +var disablingItself = []selfChange{ + {"a dry run beside a second admin", []change{holding("editor@example.com", "admin")}, + []string{"account:disable", "admin@example.com"}}, + {"an applied run beside a second admin", []change{holding("editor@example.com", "admin")}, + []string{"account:disable", "admin@example.com", "-yes"}}, + {"an address typed in capitals between spaces", []change{holding("editor@example.com", "admin")}, + []string{"account:disable", " Admin@Example.COM ", "-yes"}}, + {"a dry run of the single admin", nil, []string{"account:disable", "admin@example.com"}}, + {"an applied run of the single admin", nil, []string{"account:disable", "admin@example.com", "-yes"}}, +} - tests := []struct { - name string - setup []string - args []string - }{ - {"a dry run beside a second admin", []string{holding("editor@example.com", "admin")}, - []string{"account:role", "admin@example.com", "author"}}, - {"an applied run beside a second admin", []string{holding("editor@example.com", "admin")}, - []string{"account:role", "admin@example.com", "author", "-yes"}}, - {"an address typed in capitals between spaces", []string{holding("editor@example.com", "admin")}, - []string{"account:role", " Admin@Example.COM ", "author", "-yes"}}, - {"the role it already holds", nil, []string{"account:role", "admin@example.com", "admin", "-yes"}}, - {"a dry run of the single admin", nil, []string{"account:role", "admin@example.com", "editor"}}, - {"an applied run of the single admin", nil, []string{"account:role", "admin@example.com", "editor", "-yes"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() +// changingItsOwnRole are the runs in which the admin changes its own role. +var changingItsOwnRole = []selfChange{ + {"a dry run beside a second admin", []change{holding("editor@example.com", "admin")}, + []string{"account:role", "admin@example.com", "author"}}, + {"an applied run beside a second admin", []change{holding("editor@example.com", "admin")}, + []string{"account:role", "admin@example.com", "author", "-yes"}}, + {"an address typed in capitals between spaces", []change{holding("editor@example.com", "admin")}, + []string{"account:role", " Admin@Example.COM ", "author", "-yes"}}, + {"the role it already holds", nil, []string{"account:role", "admin@example.com", "admin", "-yes"}}, + {"a dry run of the single admin", nil, []string{"account:role", "admin@example.com", "editor"}}, + {"an applied run of the single admin", nil, []string{"account:role", "admin@example.com", "editor", "-yes"}}, +} - address := recorded(t) - run(t, address, tt.setup...) +func TestDisableRefusesTheActingAccountDisablingItself(t *testing.T) { + t.Parallel() - refuses(t, overseen(address), "myapp: the account admin@example.com cannot change its own role\n", - slices.Concat(tt.args, []string{"-as", "admin@example.com"})...) + refusesSelf(t, "myapp: the account admin@example.com cannot disable itself\n", disablingItself) +} - if held := records(t, address); len(held) != 0 { - t.Errorf("records = %v, want none", held) - } - if after := standingOf(t, address, "admin@example.com"); after != `role "admin", disabled false` { - t.Errorf("admin@example.com holds %s, want its role kept", after) - } - }) - } +func TestSetRoleRefusesTheActingAccountChangingItsOwnRole(t *testing.T) { + t.Parallel() + + refusesSelf(t, "myapp: the account admin@example.com cannot change its own role\n", changingItsOwnRole) } diff --git a/gonsole/auth/record_test.go b/gonsole/auth/record_test.go index b824d97..ae14fd1 100644 --- a/gonsole/auth/record_test.go +++ b/gonsole/auth/record_test.go @@ -5,32 +5,33 @@ package auth_test import ( "context" "fmt" - "strings" + "reflect" "testing" "time" - "github.com/jackc/pgx/v5" - "github.com/gopherium/framework/gonsole" "github.com/gopherium/framework/gonsole/auth" "github.com/gopherium/framework/gonsole/testkit" ) -// stored is one command record as the records table holds it. +// stored is one command record as the records fake holds it. type stored struct { - actor, account, command, args, flags string + actor, account, command string + args []string + flags map[string]string } -// guarded is a config naming manage_users, recording within a second. +// guarded is a config over the in-memory stores naming manage_users, recording within a second. func guarded() auth.Config { return auth.Config{ Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { return vocabulary, nil }, Capability: "manage_users", RecordTimeout: time.Second, + Stores: memoryStores, } } -// recording returns a program over the database at address whose applied guarded commands cfg records. +// recording returns a program over the world at address whose applied guarded commands cfg records. func recording( address string, cfg auth.Config, settings map[string]string, commands ...gonsole.Command, ) gonsole.Program { @@ -44,41 +45,36 @@ func recording( return p } -// recorded returns the address of a seeded database that also holds the records table. +// recorded returns the address of a seeded world the records step also ran on. func recorded(t *testing.T) string { t.Helper() address := seeded(t) - if err := auth.RecordMigration().Run(t.Context(), address); err != nil { + if err := recordsStep.Run(t.Context(), address); err != nil { t.Fatalf("applying the records schema: %v", err) } return address } -// records returns every record the database at address holds, oldest first. +// records returns every record the world at address holds, oldest first. func records(t *testing.T, address string) []stored { t.Helper() - conn, err := pgx.Connect(t.Context(), address) - if err != nil { - t.Fatalf("connecting to %s: %v", address, err) - } - defer func() { _ = conn.Close(t.Context()) }() - rows, err := conn.Query(t.Context(), - "SELECT actor, coalesce(account_id::text, ''), command, args::text, flags::text "+ - "FROM gonsole.records ORDER BY applied_at") - if err != nil { - t.Fatalf("reading the records: %v", err) - } - held, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (stored, error) { - var r stored - err := row.Scan(&r.actor, &r.account, &r.command, &r.args, &r.flags) - return r, err - }) - if err != nil { - t.Fatalf("reading the records: %v", err) + var held []stored + for _, entry := range worldAt(t, address).records.all() { + held = append(held, stored{ + actor: entry.Actor, account: accountID(entry), command: entry.Command, args: entry.Args, flags: entry.Flags, + }) } return held } +// accountID returns the id of the account entry names, empty for none. +func accountID(entry auth.Entry) string { + if entry.AccountID == nil { + return "" + } + return *entry.AccountID +} + // command returns a guarded command named name that writes nothing and prints done. func command(name string) gonsole.Command { return gonsole.Command{ @@ -104,30 +100,10 @@ func TestRecordStoresTheActorTheCommandAndItsArguments(t *testing.T) { admin := account(t, storeAt(t, address), "admin@example.com") want := []stored{{ actor: "admin@example.com", account: admin.ID.String(), command: "account:role", - args: `["Editor@Example.com", "author"]`, flags: "{}", + args: []string{"Editor@Example.com", "author"}, flags: map[string]string{}, }} - if held := records(t, address); fmt.Sprint(held) != fmt.Sprint(want) { - t.Errorf("records = %v, want %v", held, want) - } -} - -func TestRecordStoresUnderEveryQueryMode(t *testing.T) { - t.Parallel() - - for _, mode := range []string{"exec", "simple_protocol"} { - t.Run(mode, func(t *testing.T) { - t.Parallel() - - address := recorded(t) - p := recording(address+"&default_query_exec_mode="+mode, guarded(), nil, auth.Commands(guarded())...) - - got := testkit.Run(t, p, "", "account:grant-role", "-role", "editor", "-yes", "-as", "admin@example.com") - - held := records(t, address) - if got.Code != gonsole.ExitDone || len(held) != 1 || held[0].args != "[]" || held[0].flags != `{"role": "editor"}` { - t.Errorf("code %d, records %v, stderr %q, want 0 and the grant recorded", got.Code, held, got.Stderr) - } - }) + if held := records(t, address); !reflect.DeepEqual(held, want) { + t.Errorf("records = %#v, want %#v", held, want) } } @@ -137,17 +113,14 @@ func TestRecordStoresATimeOrderedID(t *testing.T) { address := recorded(t) p := recording(address, guarded(), nil, command("report:purge")) testkit.Run(t, p, "", "report:purge", "-as", "admin@example.com") - conn, err := pgx.Connect(t.Context(), address) - if err != nil { - t.Fatalf("connecting: %v", err) - } - defer func() { _ = conn.Close(t.Context()) }() var version string - err = conn.QueryRow(t.Context(), "SELECT substr(id::text, 15, 1) FROM gonsole.records").Scan(&version) + if held := worldAt(t, address).records.all(); len(held) == 1 { + version = held[0].ID[14:15] + } - if err != nil || version != "7" { - t.Errorf("id version = %q, %v, want 7", version, err) + if version != "7" { + t.Errorf("id version = %q, want 7", version) } } @@ -176,8 +149,9 @@ func TestRecordStoresAnEmptyListForACommandWithoutArguments(t *testing.T) { if got.Code != gonsole.ExitDone || len(held) != 1 { t.Fatalf("code %d, records %v, want 0 and one record, stderr %q", got.Code, held, got.Stderr) } - if held[0].args != "[]" || held[0].flags != `{"role": "editor"}` { - t.Errorf("args %s, flags %s, want an empty list and the role flag", held[0].args, held[0].flags) + typed, want := []any{held[0].args, held[0].flags}, []any{[]string{}, map[string]string{"role": "editor"}} + if !reflect.DeepEqual(typed, want) { + t.Errorf("args and flags %#v, want an empty list and the role flag", typed) } } @@ -190,8 +164,9 @@ func TestRecordStoresNoAccountForAnAddressNoAccountHolds(t *testing.T) { got := testkit.Run(t, p, "", "report:purge", "-as", "nobody@example.com") held := records(t, address) - if got.Code != gonsole.ExitDone || len(held) != 1 || held[0].account != "" || held[0].actor != "nobody@example.com" { - t.Errorf("code %d, records %v, want 0 and one record with no account", got.Code, held) + want := []stored{{actor: "nobody@example.com", command: "report:purge", args: []string{}, flags: map[string]string{}}} + if got.Code != gonsole.ExitDone || !reflect.DeepEqual(held, want) { + t.Errorf("code %d, records %#v, want 0 and one record with no account", got.Code, held) } } @@ -204,8 +179,11 @@ func TestRecordNamesAMalformedTimeout(t *testing.T) { got := testkit.Run(t, p, "", "report:purge", "-as", "admin@example.com") want := `myapp: MYAPP_COMMAND_RECORD_TIMEOUT: must be a duration like 30s, got "soon"` + "\n" - if got.Code != gonsole.ExitFailed || got.Stderr != want || len(records(t, address)) != 0 { - t.Errorf("code %d, stderr %q, want 1 and %q with no record", got.Code, got.Stderr, want) + if got.Code != gonsole.ExitFailed || got.Stderr != want { + t.Errorf("code %d, stderr %q, want 1 and %q", got.Code, got.Stderr, want) + } + if held := records(t, address); len(held) != 0 { + t.Errorf("records = %v, want none", held) } } @@ -224,53 +202,3 @@ func TestRecordRefusesATimeoutThatIsNotAboveZero(t *testing.T) { t.Errorf("code %d, stderr %q, want 1 and %q", got.Code, got.Stderr, want) } } - -func TestRecordEndsAtItsTimeout(t *testing.T) { - t.Parallel() - - address := recorded(t) - holder, err := pgx.Connect(t.Context(), address) - if err != nil { - t.Fatalf("connecting: %v", err) - } - defer func() { _ = holder.Close(t.Context()) }() - 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 := recording(address, guarded(), map[string]string{"MYAPP_COMMAND_RECORD_TIMEOUT": "200ms"}, command("report:purge")) - started := time.Now() - - got := testkit.Run(t, p, "", "report:purge", "-as", "admin@example.com") - - if got.Code != gonsole.ExitFailed || !strings.HasPrefix(got.Stderr, "myapp: record report:purge: ") { - t.Errorf("code %d, stderr %q, want 1 naming the record", got.Code, got.Stderr) - } - if waited := time.Since(started); waited > 5*time.Second { - t.Errorf("the run took %v, want about the timeout of 200ms", waited) - } -} - -func TestRecordNamesADatabaseItCannotReach(t *testing.T) { - t.Parallel() - - p := recording("postgres://postgres@127.0.0.1:1/none?connect_timeout=1", guarded(), nil, command("report:purge")) - - got := testkit.Run(t, p, "", "report:purge", "-as", "admin@example.com") - - if got.Code != gonsole.ExitFailed || !strings.HasPrefix(got.Stderr, "myapp: record report:purge: ") { - t.Errorf("code %d, stderr %q, want 1 naming the record", got.Code, got.Stderr) - } -} - -func TestRecordNeedsTheDatabaseSetting(t *testing.T) { - t.Parallel() - - p := recording("", guarded(), nil, command("report:purge")) - p.Database = "" - - got := testkit.Run(t, p, "", "report:purge", "-as", "admin@example.com") - - if want := "myapp: gonsole: no database setting in this call\n"; got.Code != gonsole.ExitFailed || got.Stderr != want { - t.Errorf("code %d, stderr %q, want 1 and %q", got.Code, got.Stderr, want) - } -} diff --git a/gonsole/auth/records_test.go b/gonsole/auth/records_test.go index 057bead..755112a 100644 --- a/gonsole/auth/records_test.go +++ b/gonsole/auth/records_test.go @@ -4,6 +4,7 @@ package auth_test import ( "encoding/json" + "reflect" "strconv" "strings" "testing" @@ -13,7 +14,17 @@ import ( "github.com/gopherium/framework/gonsole/testkit" ) -// reading returns a program over the database at address that applies guarded commands and lists their records. +// listedRecord is one record in the document account:records answers. +type listedRecord struct { + AppliedAt string `json:"applied_at"` + Actor string `json:"actor"` + AccountID *string `json:"account_id"` + Command string `json:"command"` + Args []string `json:"args"` + Flags map[string]string `json:"flags"` +} + +// reading returns a program over the world at address that applies guarded commands and lists their records. func reading(address string, cfg auth.Config, settings map[string]string) gonsole.Program { commands := append(auth.Commands(cfg), auth.Records(cfg), command("report:purge"), command("report:export")) return authorizing(address, cfg, settings, commands...) @@ -45,6 +56,15 @@ func lines(text string) []string { return strings.Split(strings.TrimSuffix(text, "\n"), "\n") } +// refusesListing runs account:records on the world at address and fails the test unless it answers failure. +func refusesListing(t *testing.T, address string, failure error) { + t.Helper() + got := testkit.Run(t, reading(address, limited(50), nil), "", "account:records") + if want := failed(failure); got != want { + t.Errorf("Run() = %+v, want %+v with nothing listed", got, want) + } +} + // titled returns a guarded command named name that takes a title and prints done. func titled(name string) gonsole.Command { cmd := command(name) @@ -93,27 +113,22 @@ func TestRecordsAnswersOneDocument(t *testing.T) { address := recorded(t) p := reading(address, limited(50), nil) apply(t, p, []string{"account:grant-role", "-role", "editor", "-yes"}) - admin := account(t, storeAt(t, address), "admin@example.com") + admin := account(t, storeAt(t, address), "admin@example.com").ID.String() got := testkit.Run(t, p, "", "account:records", "-json") var answer struct { - Records []struct { - AppliedAt string `json:"applied_at"` - Actor string `json:"actor"` - AccountID *string `json:"account_id"` - Command string `json:"command"` - Args []string `json:"args"` - Flags map[string]string `json:"flags"` - } `json:"records"` + Records []listedRecord `json:"records"` } if err := json.Unmarshal([]byte(got.Stdout), &answer); err != nil || len(answer.Records) != 1 { t.Fatalf("stdout %q, want one record, error %v", got.Stdout, err) } held := answer.Records[0] - if held.AppliedAt == "" || held.Actor != "admin@example.com" || held.AccountID == nil || - *held.AccountID != admin.ID.String() || held.Command != "account:grant-role" || - held.Args == nil || len(held.Args) != 0 || held.Flags["role"] != "editor" { + want := listedRecord{ + AppliedAt: held.AppliedAt, Actor: "admin@example.com", AccountID: &admin, Command: "account:grant-role", + Args: []string{}, Flags: map[string]string{"role": "editor"}, + } + if held.AppliedAt == "" || !reflect.DeepEqual(held, want) { t.Errorf("record = %+v, want the grant by the admin", held) } } @@ -217,35 +232,31 @@ func TestRecordsPassesAFailedReadThrough(t *testing.T) { t.Parallel() address := recorded(t) - run(t, address, "ALTER TABLE gonsole.records DROP COLUMN flags") - p := reading(address, limited(50), nil) - - got := testkit.Run(t, p, "", "account:records") + worldAt(t, address).records.latestErr = errRead - if got.Code != gonsole.ExitFailed || got.Stdout != "" || !strings.Contains(got.Stderr, "flags") { - t.Errorf("code %d, stdout %q, stderr %q, want 1 naming the missing column", got.Code, got.Stdout, got.Stderr) - } + refusesListing(t, address, errRead) } -func TestRecordsNamesADatabaseItCannotReach(t *testing.T) { +func TestRecordsPassesAFailedPresenceCheckThrough(t *testing.T) { t.Parallel() - p := reading("postgres://postgres@127.0.0.1:1/none?connect_timeout=1", limited(50), nil) - - got := testkit.Run(t, p, "", "account:records") + address := recorded(t) + worldAt(t, address).records.heldErr = errHeld - if got.Code != gonsole.ExitFailed || got.Stdout != "" || - !strings.HasPrefix(got.Stderr, "myapp: failed to connect to ") { - t.Errorf("code %d, stdout %q, stderr %q, want 1 and nothing listed", got.Code, got.Stdout, got.Stderr) - } + refusesListing(t, address, errHeld) } func TestRecordsListsTheFlagsByName(t *testing.T) { t.Parallel() address := recorded(t) - run(t, address, `INSERT INTO gonsole.records (id, actor, command, args, flags) VALUES (gen_random_uuid(), `+ - `'admin@example.com', 'report:purge', '[]', '{"zone": "d", "after": "a", "limit": "c", "kind": "b"}')`) + planted := auth.Entry{ + ID: "019a0000-0000-7000-8000-000000000001", Actor: "admin@example.com", Command: "report:purge", + Flags: map[string]string{"zone": "d", "after": "a", "limit": "c", "kind": "b"}, + } + if err := worldAt(t, address).records.Insert(t.Context(), planted); err != nil { + t.Fatalf("Insert() = %v", err) + } p := reading(address, limited(50), nil) got := testkit.Run(t, p, "", "account:records") @@ -278,23 +289,3 @@ func TestRecordsQuotesAValueThatWouldReadWrongly(t *testing.T) { } } } - -func TestRecordsListsTheNewerIDFirstWithinOneMoment(t *testing.T) { - t.Parallel() - - address := recorded(t) - run(t, address, - `INSERT INTO gonsole.records (id, applied_at, actor, command, args, flags) VALUES `+ - `('01920000-0000-7000-8000-000000000001', '2026-09-30 10:00:00+00', 'admin@example.com', `+ - `'report:purge', '[]', '{}'), `+ - `('01920000-0000-7000-8000-000000000002', '2026-09-30 10:00:00+00', 'admin@example.com', `+ - `'report:export', '[]', '{}')`) - p := reading(address, limited(50), nil) - - got := testkit.Run(t, p, "", "account:records") - - listing := lines(got.Stdout) - if len(listing) != 2 || !strings.HasSuffix(listing[0], "report:export") { - t.Errorf("stdout %q, want the export, with the newer id, first", got.Stdout) - } -} diff --git a/gonsole/auth/role_test.go b/gonsole/auth/role_test.go index 98882e9..dc32681 100644 --- a/gonsole/auth/role_test.go +++ b/gonsole/auth/role_test.go @@ -141,7 +141,7 @@ func TestSetRoleKeepsTheLastAccountUnderAPrivilegedRoleAPluginDeclares(t *testin Known: []string{"admin", "editor", "author", "superadmin"}, Privileged: gouncer.Roles{"admin", "superadmin"}, }, nil - }} + }, Stores: memoryStores} got := testkit.Run(t, program(address, auth.SetRole(cfg)), "", "account:role", "admin@example.com", "author", "-yes") @@ -162,7 +162,7 @@ func TestSetRoleFailsWhenTheRolesCannotBeRead(t *testing.T) { cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { return auth.Roles{}, errors.New("the plugin table is locked") - }} + }, Stores: memoryStores} got := testkit.Run(t, program(seeded(t), auth.SetRole(cfg)), "", "account:role", "editor@example.com", "author") diff --git a/gonsole/auth/schema_test.go b/gonsole/auth/schema_test.go deleted file mode 100644 index 4d1a157..0000000 --- a/gonsole/auth/schema_test.go +++ /dev/null @@ -1,226 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package auth_test - -import ( - "context" - "errors" - "strings" - "sync" - "testing" - "time" - - "github.com/jackc/pgx/v5" - "github.com/pressly/goose/v3/lock" - - "github.com/gopherium/framework/gonsole" - "github.com/gopherium/framework/gonsole/auth" -) - -func TestMigrationAppliesTheAccountSchemaAsOftenAsItRuns(t *testing.T) { - t.Parallel() - - address := empty(t) - step := auth.Migration() - - for run := range 2 { - if err := step.Run(t.Context(), address); err != nil { - t.Fatalf("run %d: Run() = %v, want nil", run+1, err) - } - } - - if step.Name != "accounts" { - t.Errorf("Name = %q, want accounts", step.Name) - } - if _, err := storeAt(t, address).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() - - address := empty(t) - step := auth.RecordMigration() - - for run := range 2 { - if err := step.Run(t.Context(), address); err != nil { - t.Fatalf("run %d: Run() = %v, want nil", run+1, err) - } - } - - if step.Name != "records" { - t.Errorf("Name = %q, want records", step.Name) - } - if !exists(t, address, "gonsole.records") || !exists(t, address, "gonsole.goose_db_version") { - t.Errorf("gonsole.records and its version table are missing after the migration") - } -} - -func TestEveryStepWaitsForTheMigrationLock(t *testing.T) { - t.Parallel() - - tests := []struct { - step gonsole.Step - table string - }{ - {auth.Migration(), "auth.users"}, - {auth.RecordMigration(), "gonsole.records"}, - } - for _, tt := range tests { - t.Run(tt.step.Name, func(t *testing.T) { - t.Parallel() - - address := empty(t) - holdMigrationLock(t, address) - ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) - defer cancel() - - err := tt.step.Run(ctx, address) - - if !errors.Is(err, context.DeadlineExceeded) || exists(t, address, 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 []gonsole.Step{auth.Migration(), auth.RecordMigration()} { - t.Run(step.Name, func(t *testing.T) { - t.Parallel() - - address := empty(t) - failures := make(chan error, 4) - var runs sync.WaitGroup - for range 4 { - runs.Go(func() { failures <- step.Run(t.Context(), address) }) - } - runs.Wait() - close(failures) - - for err := range failures { - if 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 - }{ - {auth.Migration(), "auth"}, - {auth.RecordMigration(), "gonsole"}, - } - for _, tt := range tests { - t.Run(tt.step.Name, func(t *testing.T) { - t.Parallel() - - address := empty(t) - holder, err := pgx.Connect(t.Context(), address) - if err != nil { - t.Fatalf("connecting to %s: %v", address, err) - } - defer func() { _ = holder.Close(context.Background()) }() - 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(), address) }() - awaitSession(t, address, holder, ran, "wait_event_type", "Lock") - - 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 []gonsole.Step{auth.Migration(), auth.RecordMigration()} { - t.Run(step.Name, func(t *testing.T) { - t.Parallel() - - address := empty(t) - run(t, address, "CREATE TABLE items (x int)") - holder := holdMigrationLock(t, address) - ran := make(chan error, 1) - go func() { ran <- step.Run(t.Context(), address) }() - awaitSession(t, address, holder, ran, "query", "%advisory%") - - _, built := holder.Exec(t.Context(), "CREATE INDEX CONCURRENTLY items_x ON items (x)") - if _, err := holder.Exec(t.Context(), "SELECT pg_advisory_unlock($1)", 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 TestRecordMigrationNamesAnAddressItCannotRead(t *testing.T) { - t.Parallel() - - err := auth.RecordMigration().Run(t.Context(), "postgres://%zz") - - if err == nil || !strings.HasPrefix(err.Error(), "open the database: ") { - t.Errorf("Run() = %v, want the address named unreadable", err) - } -} - -func TestRecordMigrationNamesADatabaseItCannotReach(t *testing.T) { - t.Parallel() - - err := auth.RecordMigration().Run(t.Context(), "postgres://postgres@127.0.0.1:1/none?connect_timeout=1") - - if err == nil || !strings.HasPrefix(err.Error(), "apply the records schema: ") { - t.Errorf("Run() = %v, want the records schema named", err) - } -} - -func TestRecordMigrationNamesASchemaItCannotCreate(t *testing.T) { - t.Parallel() - - address := empty(t) - run(t, address, "DO $$ BEGIN EXECUTE format("+ - "'ALTER DATABASE %I SET default_transaction_read_only = on', current_database()); END $$") - - err := auth.RecordMigration().Run(t.Context(), address) - - if err == nil || !strings.Contains(err.Error(), "create the gonsole schema: ") { - t.Errorf("Run() = %v, want the schema named", err) - } -} - -func TestRecordMigrationNamesAStepItCannotApply(t *testing.T) { - t.Parallel() - - address := empty(t) - run(t, address, "CREATE SCHEMA gonsole", "CREATE TABLE gonsole.records (id int)") - - err := auth.RecordMigration().Run(t.Context(), address) - - if err == nil || !strings.HasPrefix(err.Error(), "apply the records schema: ") { - t.Errorf("Run() = %v, want the records schema named", err) - } -} diff --git a/gonsole/auth/standing_test.go b/gonsole/auth/standing_test.go index 9134ea1..c4a4d35 100644 --- a/gonsole/auth/standing_test.go +++ b/gonsole/auth/standing_test.go @@ -35,7 +35,7 @@ Flags: apply the change, a dry run without it ` -// withDisabledEditor returns the address of a seeded database whose editor is disabled. +// withDisabledEditor returns the address of a seeded world whose editor is disabled. func withDisabledEditor(t *testing.T) string { t.Helper() address := seeded(t) @@ -140,7 +140,7 @@ func TestDisableFailsWhenTheRolesCannotBeRead(t *testing.T) { cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { return auth.Roles{}, errors.New("the plugin table is locked") - }} + }, Stores: memoryStores} got := testkit.Run(t, program(seeded(t), standingCommands(cfg)...), "", "account:disable", "editor@example.com") diff --git a/gonsole/auth/stores_test.go b/gonsole/auth/stores_test.go index 0710e05..972ccac 100644 --- a/gonsole/auth/stores_test.go +++ b/gonsole/auth/stores_test.go @@ -6,63 +6,37 @@ import ( "context" "encoding/json" "io" - "maps" "testing" "time" - "github.com/gopherium/gouncer/authkit/postgres" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/gopherium/framework/gonsole/auth" "github.com/gopherium/framework/gonsole/auth/recordstest" ) -// plantRecord inserts one record as applied at a given time, finding the account the acting address names. -const plantRecord = `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)` - -// poolAt returns a pool over the database at address, closed when the test ends. -func poolAt(t *testing.T, address string) *pgxpool.Pool { - t.Helper() - pool, err := pgxpool.New(t.Context(), address) - if err != nil { - t.Fatalf("opening %s: %v", address, err) - } - t.Cleanup(pool.Close) - return pool -} - -// postgresFixture returns the PostgreSQL record store of a fresh database and the hooks recordstest drives it with. -func postgresFixture(t *testing.T) recordstest.Fixture { +// memoryFixture returns the records fake of a fresh world and the hooks recordstest drives it with. +func memoryFixture(t *testing.T) recordstest.Fixture { t.Helper() address := migrated(t) - pool := poolAt(t, address) - accounts := postgres.NewUserStore(pool) + held := worldAt(t, address) return recordstest.Fixture{ - Records: auth.PostgresRecords(pool), - Migrate: func(ctx context.Context) error { return auth.RecordMigration().Run(ctx, address) }, + Records: held.records, + Migrate: func(ctx context.Context) error { return recordsStep.Run(ctx, 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 { + created := auth.Account{Email: email, Name: "Maria Perez", Password: demoPassword, Role: "admin"} + if err := auth.EnsureAccounts(ctx, held.accounts, []auth.Account{created}, io.Discard); err != nil { return "", err } - user, err := accounts.UserByEmail(ctx, email) + user, err := held.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, plantRecord, entry.ID, appliedAt, entry.Actor, entry.Command, - append([]string{}, entry.Args...), flags) - return err - }, + Plant: held.records.plant, } } -func TestPostgresRecordsKeepTheContract(t *testing.T) { +func TestTheInMemoryRecordsKeepTheContract(t *testing.T) { t.Parallel() - recordstest.Run(t, postgresFixture) + recordstest.Run(t, memoryFixture) } func TestAnEntryKeepsTheDocumentAccountRecordsAnswers(t *testing.T) { @@ -84,13 +58,3 @@ func TestAnEntryKeepsTheDocumentAccountRecordsAnswers(t *testing.T) { t.Errorf("json.Marshal() = %s, %v, want %s", document, err, want) } } - -func TestThePostgresUserStoreIsAnAccountStore(t *testing.T) { - t.Parallel() - - var store any = (*postgres.UserStore)(nil) - - if _, ok := store.(auth.Accounts); !ok { - t.Error("*postgres.UserStore is not an auth.Accounts, want every account command able to run on it") - } -} From 9dceb0d6ec0bbe7c365ef01ef645360c763202ee Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 13 Oct 2026 16:51:54 +0200 Subject: [PATCH 02/12] test(gonsole/auth): keep the address counter docblock to what it holds --- gonsole/auth/memory_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gonsole/auth/memory_test.go b/gonsole/auth/memory_test.go index 69d44f8..5d5c883 100644 --- a/gonsole/auth/memory_test.go +++ b/gonsole/auth/memory_test.go @@ -146,7 +146,7 @@ func newestFirst(a, b auth.Entry) int { // worlds holds every live world by its address. var worlds sync.Map -// addresses counts the worlds made so far, so each takes an address of its own. +// addresses counts the worlds made so far. var addresses atomic.Int64 // fresh registers an empty world under an address of its own, dropped when the test ends, and returns the address. From 51a5a24619f5ad4e30725300faa40bd99fcb0ee1 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 13 Oct 2026 16:51:54 +0200 Subject: [PATCH 03/12] feat(gonsole/auth): require Config.Stores --- gonsole/auth/builder_test.go | 64 +++++++++++++++++++++++++++++++++++- gonsole/auth/config.go | 26 ++++----------- gonsole/auth/record.go | 38 +++------------------ 3 files changed, 73 insertions(+), 55 deletions(-) diff --git a/gonsole/auth/builder_test.go b/gonsole/auth/builder_test.go index 5311389..0240c7e 100644 --- a/gonsole/auth/builder_test.go +++ b/gonsole/auth/builder_test.go @@ -17,7 +17,7 @@ import ( "github.com/gopherium/framework/gonsole/testkit" ) -// unreachable is a database address no world answers at, so a run that builds its stores from the setting fails. +// unreachable is a database address no world answers at. const unreachable = "memory://unreachable" // The errors a failing builder, record store or release answers. @@ -140,6 +140,68 @@ func TestABuilderFailureReachesEveryCaller(t *testing.T) { } } +// noStores is the failure of every caller whose config holds a nil Stores. +const noStores = "gonsole/auth: Config.Stores is nil, " + + "set it to the Stores of gonsole/auth/postgres or gonsole/auth/sqlite" + +func TestEveryCallerRefusesAConfigWithoutStores(t *testing.T) { + t.Parallel() + + cfg := limited(50) + cfg.Stores = nil + purge := []string{"report:purge", "-as", "admin@example.com"} + tests := []struct { + name string + build func(address string) gonsole.Program + stdin string + args []string + want string + }{ + {"Authorize", func(address string) gonsole.Program { + return authorizing(address, cfg, nil, command("report:purge")) + }, "", purge, "myapp: " + noStores + "\n"}, + {"Record", func(address string) gonsole.Program { + return recording(address, cfg, nil, command("report:purge")) + }, "", purge, "myapp: record report:purge: " + noStores + "\n"}, + {"account:records", func(address string) gonsole.Program { + return recording(address, cfg, nil, auth.Records(cfg)) + }, "", []string{"account:records"}, "myapp: " + noStores + "\n"}, + {"account:list", func(address string) gonsole.Program { + return recording(address, cfg, nil, auth.List(cfg)) + }, "", []string{"account:list"}, "myapp: " + noStores + "\n"}, + {"account:create-admin", func(address string) gonsole.Program { + return program(address, auth.CreateAdmin(cfg)) + }, demoPassword + "\n", creating("admin"), migratedLine + "myapp: " + noStores + "\n"}, + {"account:grant-role", func(address string) gonsole.Program { + return program(address, auth.GrantRole(cfg)) + }, "", []string{"account:grant-role", "-role", "editor", "-yes", "-as", "admin@example.com"}, + migratedLine + "myapp: " + noStores + "\n"}, + {"account:role", func(address string) gonsole.Program { + return program(address, auth.SetRole(cfg)) + }, "", []string{"account:role", "editor@example.com", "author", "-yes", "-as", "admin@example.com"}, + "myapp: " + noStores + "\n"}, + {"account:disable", func(address string) gonsole.Program { + return program(address, auth.Disable(cfg)) + }, "", []string{"account:disable", "editor@example.com", "-yes", "-as", "admin@example.com"}, + "myapp: " + noStores + "\n"}, + {"account:enable", func(address string) gonsole.Program { + return program(address, auth.Enable(cfg)) + }, "", []string{"account:enable", "editor@example.com", "-yes", "-as", "admin@example.com"}, + "myapp: " + noStores + "\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := testkit.Run(t, tt.build(fresh(t)), tt.stdin, tt.args...) + + if got.Code != gonsole.ExitFailed || got.Stderr != tt.want { + t.Errorf("code %d, stderr %q, want 1 and %q", got.Code, got.Stderr, tt.want) + } + }) + } +} + func TestRecordReleasesTheBuiltStoresAfterTheInsert(t *testing.T) { t.Parallel() diff --git a/gonsole/auth/config.go b/gonsole/auth/config.go index 3b67898..4c649ef 100644 --- a/gonsole/auth/config.go +++ b/gonsole/auth/config.go @@ -11,12 +11,14 @@ import ( "time" "github.com/gopherium/gouncer" - "github.com/gopherium/gouncer/authkit/postgres" - "github.com/jackc/pgx/v5/pgxpool" "github.com/gopherium/framework/gonsole" ) +// errNoStores is what every caller answers for a config whose Stores is nil. +var errNoStores = errors.New( + "gonsole/auth: Config.Stores is nil, set it to the Stores of gonsole/auth/postgres or gonsole/auth/sqlite") + // Config is what a program hands its account commands. type Config struct { // Roles returns the program's role vocabulary. @@ -27,7 +29,7 @@ type Config struct { RecordTimeout time.Duration // RecordsLimit is how many records account:records lists when the COMMAND_RECORDS_LIMIT setting is empty. RecordsLimit int - // Stores builds a call's stores and the release of what it opened, if any, nil for PostgreSQL at the database setting. + // Stores builds a call's stores and the release of what it opened, if any, and is required. Stores func(ctx context.Context, call gonsole.Call) (Stores, func(context.Context) error, error) } @@ -71,9 +73,7 @@ func (c Config) withStore(ctx context.Context, call gonsole.Call, use func(store // withStores runs use over the stores of the call and releases them after, even when use panics. func (c Config) withStores(ctx context.Context, call gonsole.Call, use func(stores Stores) error) (err error) { if c.Stores == nil { - return withPool(ctx, call, func(pool *pgxpool.Pool) error { - return use(Stores{Accounts: postgres.NewUserStore(pool), Records: postgresRecords{db: pool}}) - }) + return errNoStores } stores, release, err := c.Stores(ctx, call) if err != nil { @@ -89,17 +89,3 @@ func (c Config) withStores(ctx context.Context, call gonsole.Call, use func(stor }() return use(stores) } - -// withPool runs use over a pool of the program's database and closes the pool after. -func withPool(ctx context.Context, call gonsole.Call, use func(pool *pgxpool.Pool) error) error { - address, err := call.DatabaseURL() - if err != nil { - return err - } - pool, err := pgxpool.New(ctx, address) - if err != nil { - return fmt.Errorf("open the database: %w", err) - } - defer pool.Close() - return use(pool) -} diff --git a/gonsole/auth/record.go b/gonsole/auth/record.go index d118dfd..ed8d51e 100644 --- a/gonsole/auth/record.go +++ b/gonsole/auth/record.go @@ -8,7 +8,6 @@ import ( "time" "github.com/google/uuid" - "github.com/jackc/pgx/v5" "github.com/gopherium/framework/gonsole" ) @@ -20,37 +19,18 @@ func Record(cfg Config) func(ctx context.Context, call gonsole.Call, command str if err != nil { return err } - insert, err := cfg.inserter(call) - if err != nil { - return err - } bounded, cancel := context.WithTimeout(ctx, timeout) defer cancel() - if err := insert(bounded, entryOf(call, command)); err != nil { + err = cfg.withStores(bounded, call, func(stores Stores) error { + return stores.Records.Insert(bounded, entryOf(call, command)) + }) + if err != nil { return fmt.Errorf("record %s: %w", command, err) } return nil } } -// inserter returns what stores the call's entry, the built stores or one connection to the database setting. -func (c Config) inserter(call gonsole.Call) (func(ctx context.Context, entry Entry) error, error) { - if c.Stores != nil { - return func(ctx context.Context, entry Entry) error { - return c.withStores(ctx, call, func(stores Stores) error { - return stores.Records.Insert(ctx, entry) - }) - }, nil - } - databaseURL, err := call.DatabaseURL() - if err != nil { - return nil, err - } - return func(ctx context.Context, entry Entry) error { - return store(ctx, databaseURL, entry) - }, nil -} - // recordTimeout returns how long storing one record may take, as the setting or the fallback names it. func (c Config) recordTimeout(env gonsole.Env) (time.Duration, error) { timeout, err := env.Duration("COMMAND_RECORD_TIMEOUT", c.RecordTimeout) @@ -63,16 +43,6 @@ func (c Config) recordTimeout(env gonsole.Env) (time.Duration, error) { return timeout, nil } -// store inserts entry into the database at databaseURL over one connection. -func store(ctx context.Context, databaseURL string, entry Entry) error { - conn, err := pgx.Connect(ctx, databaseURL) - if err != nil { - return err - } - defer func() { _ = conn.Close(context.WithoutCancel(ctx)) }() - return postgresRecords{db: conn}.Insert(ctx, entry) -} - // entryOf returns the entry recording command as call ran it, under a new UUIDv7. func entryOf(call gonsole.Call, command string) Entry { return Entry{ From 22869692c0103b2b062c1058c864a91e0717d519 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 13 Oct 2026 16:51:55 +0200 Subject: [PATCH 04/12] test(gonsole/auth/postgres): drop the byte check against the records migrations of gonsole/auth --- gonsole/auth/postgres/numbers_test.go | 50 --------------------------- 1 file changed, 50 deletions(-) delete mode 100644 gonsole/auth/postgres/numbers_test.go diff --git a/gonsole/auth/postgres/numbers_test.go b/gonsole/auth/postgres/numbers_test.go deleted file mode 100644 index 7c547fa..0000000 --- a/gonsole/auth/postgres/numbers_test.go +++ /dev/null @@ -1,50 +0,0 @@ -// 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 ea4c35c25852ed8b43453cf5242094138c9a3f42 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 13 Oct 2026 16:51:55 +0200 Subject: [PATCH 05/12] feat(gonsole/auth): remove the PostgreSQL steps and record store, now in gonsole/auth/postgres --- gonsole/auth/go.mod | 11 --- gonsole/auth/go.sum | 58 ------------- .../auth/migrations/00001_create_records.sql | 19 ----- gonsole/auth/pgrecords.go | 76 ----------------- gonsole/auth/schema.go | 81 ------------------- gonsole/auth/schema_internal_test.go | 21 ----- 6 files changed, 266 deletions(-) delete mode 100644 gonsole/auth/migrations/00001_create_records.sql delete mode 100644 gonsole/auth/pgrecords.go delete mode 100644 gonsole/auth/schema.go delete mode 100644 gonsole/auth/schema_internal_test.go diff --git a/gonsole/auth/go.mod b/gonsole/auth/go.mod index 04a10fa..06a524f 100644 --- a/gonsole/auth/go.mod +++ b/gonsole/auth/go.mod @@ -7,20 +7,9 @@ require ( github.com/gopherium/framework/gonsole v0.6.1 github.com/gopherium/gouncer v0.4.0 github.com/gopherium/gouncer/authkit v0.19.0 - github.com/gopherium/gouncer/authkit/postgres v0.13.0 - github.com/jackc/pgx/v5 v5.10.0 - github.com/pressly/goose/v3 v3.27.2 ) require ( - 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/go.sum b/gonsole/auth/go.sum index 66ad31f..0bb506a 100644 --- a/gonsole/auth/go.sum +++ b/gonsole/auth/go.sum @@ -1,10 +1,3 @@ -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/gonsole v0.6.1 h1:yi0te8BDJLuotbrd24bQDMWSJ0KgIhSIAfOjF0jpDnQ= @@ -13,58 +6,7 @@ github.com/gopherium/gouncer v0.4.0 h1:dkbCD5oeYuuzR961UKGsAvnTvF8vTstlFbN94rP4T 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/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/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/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/migrations/00001_create_records.sql b/gonsole/auth/migrations/00001_create_records.sql deleted file mode 100644 index 2205b02..0000000 --- a/gonsole/auth/migrations/00001_create_records.sql +++ /dev/null @@ -1,19 +0,0 @@ --- 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/pgrecords.go b/gonsole/auth/pgrecords.go deleted file mode 100644 index 9a5295f..0000000 --- a/gonsole/auth/pgrecords.go +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package auth - -import ( - "context" - "encoding/json" - "maps" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgxpool" -) - -// 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` - -// querier runs the statements of the PostgreSQL record store, on a pool or on one connection. -type querier interface { - // Exec runs a statement that answers no rows. - Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) - // Query runs a statement that answers rows. - Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) - // QueryRow runs a statement that answers one row. - QueryRow(ctx context.Context, sql string, args ...any) pgx.Row -} - -// postgresRecords is the record store of one PostgreSQL database. -type postgresRecords struct { - // db runs the statements. - db querier -} - -// PostgresRecords returns the record store of the PostgreSQL database behind pool. -func PostgresRecords(pool *pgxpool.Pool) RecordStore { - return postgresRecords{db: pool} -} - -// Held reports whether the database holds the gonsole.records table. -func (r postgresRecords) Held(ctx context.Context) (bool, error) { - var held bool - err := r.db.QueryRow(ctx, "SELECT to_regclass('gonsole.records') IS NOT NULL").Scan(&held) - return held, err -} - -// Insert stores entry, finding the account its actor names in auth.users. -func (r postgresRecords) Insert(ctx context.Context, entry Entry) error { - args, flags := encoded(entry) - _, err := r.db.Exec(ctx, storeRecord, entry.ID, entry.Actor, entry.Command, args, flags) - return err -} - -// Latest returns the newest entries first, at most limit of them. -func (r postgresRecords) Latest(ctx context.Context, limit int) ([]Entry, error) { - rows, err := r.db.Query(ctx, latestRecords, limit) - if err != nil { - return nil, err - } - return pgx.CollectRows(rows, func(row pgx.CollectableRow) (Entry, error) { - var held Entry - err := row.Scan(&held.ID, &held.AppliedAt, &held.Actor, &held.AccountID, &held.Command, &held.Args, &held.Flags) - return held, err - }) -} - -// encoded returns the JSON text of the entry's arguments and flags, an empty list and object for none. -func encoded(entry Entry) (string, string) { - flags := map[string]string{} - maps.Copy(flags, entry.Flags) - return string(must(json.Marshal(append([]string{}, entry.Args...)))), string(must(json.Marshal(flags))) -} diff --git a/gonsole/auth/schema.go b/gonsole/auth/schema.go deleted file mode 100644 index cddab07..0000000 --- a/gonsole/auth/schema.go +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package auth - -import ( - "context" - "database/sql" - "embed" - "fmt" - "io/fs" - - "github.com/gopherium/gouncer/authkit/postgres" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/stdlib" - "github.com/pressly/goose/v3" - "github.com/pressly/goose/v3/database" - "github.com/pressly/goose/v3/lock" - - "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" - -// Migration returns the step that applies gouncer's schema. -func Migration() gonsole.Step { - return gonsole.Step{Name: "accounts", Run: postgres.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 schema keeping the command records to the database at databaseURL. -func migrateRecords(ctx context.Context, databaseURL string) error { - config, err := pgx.ParseConfig(databaseURL) - if err != nil { - return fmt.Errorf("open the database: %w", err) - } - db := stdlib.OpenDB(*config) - defer func() { _ = db.Close() }() - if _, err := recordsProvider(db).Up(ctx); err != nil { - return fmt.Errorf("apply the records schema: %w", err) - } - return nil -} - -// recordsProvider returns the goose provider applying the records migrations over db. -func recordsProvider(db *sql.DB) *goose.Provider { - store := schemaStore{must(database.NewStore(database.DialectPostgres, recordsVersionTable)).(database.StoreExtender)} - files := must(fs.Sub(migrationFiles, "migrations")) - locker := must(lock.NewPostgresSessionLocker()) - return must(goose.NewProvider("", db, files, goose.WithStore(store), goose.WithSessionLocker(locker))) -} - -// schemaStore is goose's version store, creating the gonsole schema along with the version table. -type schemaStore struct { - database.StoreExtender -} - -// CreateVersionTable creates the gonsole schema, then the version table inside it. -func (s schemaStore) CreateVersionTable(ctx context.Context, db database.DBTxConn) error { - if _, err := db.ExecContext(ctx, "CREATE SCHEMA IF NOT EXISTS gonsole"); err != nil { - return fmt.Errorf("create the gonsole schema: %w", err) - } - return s.StoreExtender.CreateVersionTable(ctx, db) -} - -// must returns value, panicking with err, for a value whose build cannot fail at run time. -func must[T any](value T, err error) T { - if err != nil { - panic(err) - } - return value -} diff --git a/gonsole/auth/schema_internal_test.go b/gonsole/auth/schema_internal_test.go deleted file mode 100644 index f02f5ce..0000000 --- a/gonsole/auth/schema_internal_test.go +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package auth - -import ( - "errors" - "testing" -) - -func TestMustPanicsWithTheErrorOfAValueItCannotBuild(t *testing.T) { - t.Parallel() - - failed := errors.New("built from a broken input") - defer func() { - if recovered := recover(); recovered != failed { - t.Errorf("recovered %v, want the error", recovered) - } - }() - - must(0, failed) -} From 431aece3aba4c60bd9ea957f6071fb473641ff3e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 13 Oct 2026 16:51:56 +0200 Subject: [PATCH 06/12] chore(gonsole/auth): allow only the packages the module imports --- gonsole/auth/.golangci.yml | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/gonsole/auth/.golangci.yml b/gonsole/auth/.golangci.yml index 06cbae0..162cf04 100644 --- a/gonsole/auth/.golangci.yml +++ b/gonsole/auth/.golangci.yml @@ -29,18 +29,29 @@ linters: auth-purity: list-mode: strict files: - - "**/*.go" + - $all + - "!$test" allow: - $gostd - - github.com/gopherium/framework/gonsole + - github.com/gopherium/framework/gonsole$ + - github.com/gopherium/framework/gonsole/auth$ + - github.com/gopherium/gouncer$ + - github.com/gopherium/gouncer/authkit$ + - github.com/google/uuid$ + 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/recordstest$ + - github.com/gopherium/framework/gonsole/testkit$ - github.com/gopherium/gouncer$ - github.com/gopherium/gouncer/authkit$ - github.com/gopherium/gouncer/authkit/testkit$ - - github.com/gopherium/gouncer/authkit/postgres - - github.com/jackc/pgx/v5 - - github.com/google/uuid - - github.com/peterldowns/pgtestdb - - github.com/pressly/goose/v3 + - github.com/google/uuid$ forbidigo: analyze-types: true forbid: From 1748e8e6749095be08e736b3f92ded0e7b8a283a Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 13 Oct 2026 16:51:56 +0200 Subject: [PATCH 07/12] docs(gonsole/auth): note the 0.8.0 changes under Unreleased --- gonsole/auth/CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/gonsole/auth/CHANGELOG.md b/gonsole/auth/CHANGELOG.md index 4223de4..03b04d3 100644 --- a/gonsole/auth/CHANGELOG.md +++ b/gonsole/auth/CHANGELOG.md @@ -8,6 +8,17 @@ v0.x, minor releases may contain breaking changes. Releases of this module are tagged `gonsole/auth/vX.Y.Z`. +## [Unreleased] + +### Changed + +- **Breaking.** `Config.Stores` is required. Set it to `Stores` of `gonsole/auth/postgres` or `gonsole/auth/sqlite`. A nil one fails every account command, `Authorize` and `Record`. +- No longer requires `authkit/postgres`, pgx, goose or pgtestdb, so a program on SQLite links no PostgreSQL driver. + +### Removed + +- **Breaking.** `Migration`, `RecordMigration` and `PostgresRecords`. Use `Migration`, `RecordMigration` and `NewRecordStore` of `gonsole/auth/postgres`. + ## [0.7.0] - 2026-10-11 ### Changed From f6f151bf7a7d773e9ae835584a518b3fc31d47cc Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 13 Oct 2026 16:51:56 +0200 Subject: [PATCH 08/12] feat(ci): test gonsole/auth without a PostgreSQL server --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b5ec2e..5e246e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["dbkit", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire"] + module: ["dbkit", "dbkit/sqlite", "mailkit", "goncierge", "gonsole", "gonsole/auth", "gonsole/auth/sqlite", "pluginkit", "pluginkit/graphwire"] defaults: run: working-directory: ${{ matrix.module }} @@ -40,7 +40,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["dbkit/postgres", "gonsole/auth", "gonsole/auth/postgres"] + module: ["dbkit/postgres", "gonsole/auth/postgres"] defaults: run: working-directory: ${{ matrix.module }} From 6d18678430bf474145199a07b9e6a7e11c339229 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 13 Oct 2026 16:51:57 +0200 Subject: [PATCH 09/12] chore(dbkit/postgres): start the shared PostgreSQL test server from dbkit/postgres --- {gonsole/auth => dbkit/postgres}/compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename {gonsole/auth => dbkit/postgres}/compose.yaml (94%) diff --git a/gonsole/auth/compose.yaml b/dbkit/postgres/compose.yaml similarity index 94% rename from gonsole/auth/compose.yaml rename to dbkit/postgres/compose.yaml index 759dece..70e8d9e 100644 --- a/gonsole/auth/compose.yaml +++ b/dbkit/postgres/compose.yaml @@ -1,4 +1,4 @@ -name: gonsole-auth +name: dbkit-postgres services: postgres: From a54f950f59be96692be57d0cdf3a6e7b4eecfa6e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 13 Oct 2026 19:12:47 +0200 Subject: [PATCH 10/12] test(gonsole/auth): restore the grant and activation checks the move to memory weakened --- gonsole/auth/authorize_test.go | 5 ++--- gonsole/auth/grantrole_test.go | 15 ++++++++++++--- gonsole/auth/memory_test.go | 13 +++++++------ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/gonsole/auth/authorize_test.go b/gonsole/auth/authorize_test.go index c76b583..b4d8da4 100644 --- a/gonsole/auth/authorize_test.go +++ b/gonsole/auth/authorize_test.go @@ -67,9 +67,8 @@ func TestAuthorizeRefusesAnActorItCannotLetThrough(t *testing.T) { "myapp: no account answers to nobody@example.com\n"}, {"a disabled account", []change{disabling("admin@example.com")}, "admin@example.com", "myapp: the account admin@example.com is disabled\n"}, - {"an account never activated", []change{inviting("invited@example.com"), - holding("invited@example.com", "admin")}, - "invited@example.com", "myapp: the account invited@example.com was never activated\n"}, + {"an account never activated", []change{unconfirmed("unconfirmed@example.com", "admin")}, + "unconfirmed@example.com", "myapp: the account unconfirmed@example.com was never activated\n"}, {"an account holding no role", []change{holding("author@example.com", "")}, "author@example.com", "myapp: the account author@example.com holds no role, so it lacks manage_users\n"}, } diff --git a/gonsole/auth/grantrole_test.go b/gonsole/auth/grantrole_test.go index e420a24..9582678 100644 --- a/gonsole/auth/grantrole_test.go +++ b/gonsole/auth/grantrole_test.go @@ -49,6 +49,15 @@ func withRoleless(t *testing.T, count int) string { return address } +// grantedRole returns the role of a-roleless@example.com, empty for a world made with no roleless account. +func grantedRole(t *testing.T, address string, roleless int) string { + t.Helper() + if roleless == 0 { + return "" + } + return account(t, storeAt(t, address), "a-roleless@example.com").Role +} + func TestGrantRoleGivesTheRoleOnlyWithYes(t *testing.T) { t.Parallel() @@ -84,8 +93,8 @@ func TestGrantRoleGivesTheRoleOnlyWithYes(t *testing.T) { if got != want { t.Fatalf("Run() = %+v, want %+v", got, want) } - if held, err := storeAt(t, address).UserByEmail(t.Context(), "a-roleless@example.com"); held.Role != tc.role { - t.Errorf("role = %q, %v, want %q", held.Role, err, tc.role) + if role := grantedRole(t, address, tc.roleless); role != tc.role { + t.Errorf("role = %q, want %q", role, tc.role) } if held := account(t, storeAt(t, address), "admin@example.com"); held.Role != "admin" { t.Errorf("an account holding a role now holds %q, want admin kept", held.Role) @@ -280,7 +289,7 @@ func TestGrantRoleWritesUnderTheCallsContext(t *testing.T) { strings.NewReader(""), &stdout, &stderr) got := testkit.Result{Code: code, Stdout: stdout.String(), Stderr: stderr.String()} - want := testkit.Result{Code: gonsole.ExitFailed, Stderr: migratedLine + "myapp: context canceled\n"} + want := testkit.Result{Code: gonsole.ExitFailed, Stderr: migratedLine + "myapp: grant role: context canceled\n"} if got != want { t.Errorf("Run() = %+v, want %+v: exit 1, nothing granted and the ended context", got, want) } diff --git a/gonsole/auth/memory_test.go b/gonsole/auth/memory_test.go index 5d5c883..4e491af 100644 --- a/gonsole/auth/memory_test.go +++ b/gonsole/auth/memory_test.go @@ -52,7 +52,7 @@ type memoryAccounts struct { // GrantRoleToRoleless answers the end of ctx or grantErr first, the grant of the store otherwise. func (a *memoryAccounts) GrantRoleToRoleless(ctx context.Context, role string) (int64, error) { if err := ctx.Err(); err != nil { - return 0, err + return 0, fmt.Errorf("grant role: %w", err) } if a.grantErr != nil { return 0, a.grantErr @@ -256,15 +256,16 @@ func disabling(email string) change { } } -// inviting returns the change that adds an account at email that was invited and never activated. -func inviting(email string) change { +// unconfirmed returns the change that adds an account at email holding role and a password but never activated. +func unconfirmed(email, role string) change { return func(t *testing.T, store *memoryAccounts) { t.Helper() - invited, err := gouncer.NewInvitedUser(email, "Maria Perez") + user, err := gouncer.NewUser(email, "Maria Perez", demoPassword) if err != nil { - t.Fatalf("NewInvitedUser() = %v", err) + t.Fatalf("NewUser() = %v", err) } - if err := store.CreateUser(t.Context(), invited); err != nil { + user.Confirmed, user.Role = false, role + if err := store.CreateUser(t.Context(), user); err != nil { t.Fatalf("CreateUser() = %v", err) } } From 3c498c2ced9fda52cab6564250085207e15deb8a Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 13 Oct 2026 19:12:47 +0200 Subject: [PATCH 11/12] test(gonsole/auth): check that a record id is a UUIDv7 --- gonsole/auth/record_test.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/gonsole/auth/record_test.go b/gonsole/auth/record_test.go index ae14fd1..8a49e48 100644 --- a/gonsole/auth/record_test.go +++ b/gonsole/auth/record_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + "github.com/google/uuid" + "github.com/gopherium/framework/gonsole" "github.com/gopherium/framework/gonsole/auth" "github.com/gopherium/framework/gonsole/testkit" @@ -113,15 +115,25 @@ func TestRecordStoresATimeOrderedID(t *testing.T) { address := recorded(t) p := recording(address, guarded(), nil, command("report:purge")) testkit.Run(t, p, "", "report:purge", "-as", "admin@example.com") - var version string + var version uuid.Version + + held := worldAt(t, address).records.all() + if len(held) == 1 { + version = versionOf(held[0].ID) + } - if held := worldAt(t, address).records.all(); len(held) == 1 { - version = held[0].ID[14:15] + if version != 7 { + t.Errorf("records %+v, id version %d, want one record under a UUIDv7", held, version) } +} - if version != "7" { - t.Errorf("id version = %q, want 7", version) +// versionOf returns the version of id when it is a UUID in its canonical text, zero otherwise. +func versionOf(id string) uuid.Version { + parsed, err := uuid.Parse(id) + if err != nil || parsed.String() != id { + return 0 } + return parsed.Version() } func TestRecordStoresNothingForADryRun(t *testing.T) { From f90a97d9a752a8c016f3cad1e19e0bf17052a295 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 13 Oct 2026 19:12:47 +0200 Subject: [PATCH 12/12] test(gonsole/auth): check that create-admin runs the accounts step before it writes --- gonsole/auth/createadmin_test.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/gonsole/auth/createadmin_test.go b/gonsole/auth/createadmin_test.go index 6c3d81c..55a7838 100644 --- a/gonsole/auth/createadmin_test.go +++ b/gonsole/auth/createadmin_test.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "slices" + "sync/atomic" "testing" "github.com/gopherium/framework/gonsole" @@ -42,16 +43,23 @@ func TestCreateAdminCreatesAnAccountAfterTheSchema(t *testing.T) { address := fresh(t) var reads rolesRead + var before atomic.Int32 + before.Store(-1) + cfg := config(&reads, "") + cfg.Stores = func(ctx context.Context, call gonsole.Call) (auth.Stores, func(context.Context) error, error) { + before.Store(worldAt(t, address).migrations.Load()) + return memoryStores(ctx, call) + } - got := testkit.Run(t, program(address, auth.CreateAdmin(config(&reads, ""))), demoPassword+"\n", creating("admin")...) + got := testkit.Run(t, program(address, auth.CreateAdmin(cfg)), demoPassword+"\n", creating("admin")...) want := testkit.Result{Code: gonsole.ExitDone, Stdout: "Password: created user maria.perez@example.com\n", Stderr: migratedLine} if got != want { t.Fatalf("Run() = %+v, want %+v", got, want) } - if ran := worldAt(t, address).migrations.Load(); ran != 1 { - t.Errorf("the accounts step ran %d times, want once before the account", ran) + if ran := before.Load(); ran != 1 { + t.Errorf("the accounts step ran %d times before the account, want once", ran) } if held := account(t, storeAt(t, address), "maria.perez@example.com"); held.Role != "admin" || held.Name != "Maria Perez" {