From 11459588e62d24c36d85f3ae36744d77222b76d9 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Fri, 25 Sep 2026 13:56:26 +0200 Subject: [PATCH 01/10] chore(gonsole/auth): scaffold the module --- gonsole/auth/.golangci.yml | 53 ++++++++++++++++++++++++++ gonsole/auth/compose.yaml | 19 ++++++++++ gonsole/auth/doc.go | 4 ++ gonsole/auth/go.mod | 27 ++++++++++++++ gonsole/auth/go.sum | 76 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 179 insertions(+) create mode 100644 gonsole/auth/.golangci.yml create mode 100644 gonsole/auth/compose.yaml create mode 100644 gonsole/auth/doc.go create mode 100644 gonsole/auth/go.mod create mode 100644 gonsole/auth/go.sum diff --git a/gonsole/auth/.golangci.yml b/gonsole/auth/.golangci.yml new file mode 100644 index 0000000..f2cfc19 --- /dev/null +++ b/gonsole/auth/.golangci.yml @@ -0,0 +1,53 @@ +version: "2" + +linters: + default: standard + enable: + - cyclop + - depguard + - gocognit + - lll + - misspell + - revive + - unconvert + - unparam + settings: + cyclop: + max-complexity: 10 + gocognit: + min-complexity: 15 + lll: + line-length: 120 + revive: + rules: + - name: blank-imports + disabled: true + - name: exported + depguard: + rules: + auth-purity: + list-mode: strict + files: + - "**/*.go" + allow: + - $gostd + - github.com/gopherium/framework/gonsole + - github.com/gopherium/gouncer + - github.com/jackc/pgx/v5 + - github.com/google/uuid + - github.com/peterldowns/pgtestdb + exclusions: + rules: + - path: _test\.go + linters: + - cyclop + - gocognit + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/gopherium/framework diff --git a/gonsole/auth/compose.yaml b/gonsole/auth/compose.yaml new file mode 100644 index 0000000..759dece --- /dev/null +++ b/gonsole/auth/compose.yaml @@ -0,0 +1,19 @@ +name: gonsole-auth + +services: + postgres: + image: postgres:18 + environment: + POSTGRES_PASSWORD: postgres + ports: + - "5434:5432" + volumes: + - postgres-data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 2s + timeout: 2s + retries: 15 + +volumes: + postgres-data: diff --git a/gonsole/auth/doc.go b/gonsole/auth/doc.go new file mode 100644 index 0000000..abd797b --- /dev/null +++ b/gonsole/auth/doc.go @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package auth offers the account commands of a program whose accounts live in gouncer's Postgres store. +package auth diff --git a/gonsole/auth/go.mod b/gonsole/auth/go.mod new file mode 100644 index 0000000..f35f7a0 --- /dev/null +++ b/gonsole/auth/go.mod @@ -0,0 +1,27 @@ +module github.com/gopherium/framework/gonsole/auth + +go 1.27.1 + +require ( + github.com/gopherium/framework/gonsole v0.1.0 + github.com/gopherium/gouncer v0.4.0 + github.com/gopherium/gouncer/authkit v0.15.0 + github.com/gopherium/gouncer/authkit/postgres v0.11.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/peterldowns/pgtestdb v0.1.1 +) + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/pressly/goose/v3 v3.27.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.40.0 // indirect +) diff --git a/gonsole/auth/go.sum b/gonsole/auth/go.sum new file mode 100644 index 0000000..75255c2 --- /dev/null +++ b/gonsole/auth/go.sum @@ -0,0 +1,76 @@ +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.1.0 h1:wJc5sUJMg9M3V22f7OfmVoiLi6MoA4fkW8hGvETO2Y0= +github.com/gopherium/framework/gonsole v0.1.0/go.mod h1:ZBytW36Wwv5FX57Qa3J/YNS1DkvdFLVL2MnvOtJDP9g= +github.com/gopherium/gouncer v0.4.0 h1:dkbCD5oeYuuzR961UKGsAvnTvF8vTstlFbN94rP4TvI= +github.com/gopherium/gouncer v0.4.0/go.mod h1:WwPOu8LyRikot0EODySSu46zaHcWTPbl5nOes4I3H2U= +github.com/gopherium/gouncer/authkit v0.15.0 h1:yng4mH4TG65kG314eIs9gx2TU1EhLz/+XYuhZPHOlLM= +github.com/gopherium/gouncer/authkit v0.15.0/go.mod h1:eQU0bybVcopHrAP+GPOYmxfnNIDfmSZ6d8RG4YgeUw0= +github.com/gopherium/gouncer/authkit/postgres v0.11.0 h1:0eXp7xV/AYAn4NCiWLzB0BZCc9LHjLpcdZxXp30X4g0= +github.com/gopherium/gouncer/authkit/postgres v0.11.0/go.mod h1:60KQIrXfnltT+92/8wLEdlU5Mfe8FPxPJ5NFj73vxL8= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pashagolub/pgxmock/v4 v4.9.0 h1:itlO8nrVRnzkdMBXLs8pWUyyB2PC3Gku0WGIj/gGl7I= +github.com/pashagolub/pgxmock/v4 v4.9.0/go.mod h1:9L57pC193h2aKRHVyiiE817avasIPZnPwPlw3JczWvM= +github.com/peterldowns/pgtestdb v0.1.1 h1:+hBCD1DcbKeg5Sfg0G+5WNIy/Cm0ORgwMkF4ygihrmU= +github.com/peterldowns/pgtestdb v0.1.1/go.mod h1:yVWInWV0dxvmLdL2ao3nXDzWZ9+G6EhJ4gRwvI1Ozeg= +github.com/peterldowns/testy v0.0.1 h1:9a6LzvnKcL52Crzud1z7jbsAojTntCh89ho6mgsr4KU= +github.com/peterldowns/testy v0.0.1/go.mod h1:J4sm75UEzbfBIcq0zbrshWWjsJQiJ5RrhTPYKVY2Ww8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= +github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +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= From c89c2d813bf155d5cfae1f3ddb5b33599e82b0e2 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Fri, 25 Sep 2026 13:56:26 +0200 Subject: [PATCH 02/10] feat(gonsole/auth): apply the account schema and ensure demo accounts --- gonsole/auth/accounts.go | 42 +++++++++++ gonsole/auth/accounts_test.go | 127 ++++++++++++++++++++++++++++++++++ gonsole/auth/db_test.go | 36 ++++++++++ gonsole/auth/schema.go | 14 ++++ gonsole/auth/schema_test.go | 29 ++++++++ 5 files changed, 248 insertions(+) create mode 100644 gonsole/auth/accounts.go create mode 100644 gonsole/auth/accounts_test.go create mode 100644 gonsole/auth/db_test.go create mode 100644 gonsole/auth/schema.go create mode 100644 gonsole/auth/schema_test.go diff --git a/gonsole/auth/accounts.go b/gonsole/auth/accounts.go new file mode 100644 index 0000000..eb0be64 --- /dev/null +++ b/gonsole/auth/accounts.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "fmt" + "io" + + "github.com/gopherium/gouncer" + "github.com/gopherium/gouncer/authkit" +) + +// Account is one demo account a seed step ensures. +type Account struct { + // Email is the account's address. + Email string + // Name is the account's display name. + Name string + // Password is the account's demo password. + Password string + // Role is the role the account stands under. + Role string +} + +// EnsureAccounts creates each account unless its address is taken and writes one line per account. +func EnsureAccounts(ctx context.Context, store gouncer.Store, accounts []Account, w io.Writer) error { + for _, account := range accounts { + created, err := authkit.EnsureAdmin(ctx, store, account.Email, account.Name, account.Password, account.Role) + if err != nil { + return fmt.Errorf("account %s: %w", account.Email, err) + } + verb := "kept" + if created { + verb = "created" + } + if _, err := fmt.Fprintf(w, "%s %s\n", verb, account.Email); err != nil { + return err + } + } + return nil +} diff --git a/gonsole/auth/accounts_test.go b/gonsole/auth/accounts_test.go new file mode 100644 index 0000000..9015b31 --- /dev/null +++ b/gonsole/auth/accounts_test.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "errors" + "strings" + "testing" + + "github.com/gopherium/gouncer" + + "github.com/gopherium/framework/gonsole/auth" +) + +// demoPassword is the password every demo account in these tests holds. +const demoPassword = "password1234" + +// demoAccounts returns two demo accounts, one per role. +func demoAccounts() []auth.Account { + return []auth.Account{ + {Email: "admin@example.com", Name: "Maria Perez", Password: demoPassword, Role: "admin"}, + {Email: "editor@example.com", Name: "Maria Perez", Password: demoPassword, Role: "editor"}, + } +} + +// failingWriter is a writer whose every write fails. +type failingWriter struct{} + +// Write fails. +func (failingWriter) Write([]byte) (int, error) { + return 0, errors.New("the output is closed") +} + +func TestEnsureAccountsCreatesEachAccountOnce(t *testing.T) { + t.Parallel() + + store := storeAt(t, migrated(t)) + var first, second strings.Builder + + errFirst := auth.EnsureAccounts(t.Context(), store, demoAccounts(), &first) + errSecond := auth.EnsureAccounts(t.Context(), store, demoAccounts(), &second) + + if errFirst != nil || errSecond != nil { + t.Fatalf("EnsureAccounts() = %v, then %v, want nil twice", errFirst, errSecond) + } + if want := "created admin@example.com\ncreated editor@example.com\n"; first.String() != want { + t.Errorf("first run wrote %q, want %q", first.String(), want) + } + if want := "kept admin@example.com\nkept editor@example.com\n"; second.String() != want { + t.Errorf("second run wrote %q, want %q", second.String(), want) + } + editor, err := store.UserByEmail(t.Context(), "editor@example.com") + if err != nil || editor.Role != "editor" { + t.Errorf("editor = %+v, %v, want the editor role", editor, err) + } +} + +func TestEnsureAccountsGivesTheRoleToATakenAccountHoldingNone(t *testing.T) { + t.Parallel() + + store := storeAt(t, migrated(t)) + roleless, err := gouncer.NewUser("admin@example.com", "Maria Perez", demoPassword) + if err != nil { + t.Fatalf("NewUser() = %v", err) + } + if err := store.CreateUser(t.Context(), roleless); err != nil { + t.Fatalf("CreateUser() = %v", err) + } + var out strings.Builder + + err = auth.EnsureAccounts(t.Context(), store, demoAccounts()[:1], &out) + + if want := "kept admin@example.com\n"; err != nil || out.String() != want { + t.Errorf("EnsureAccounts() = %v, wrote %q, want nil and %q", err, out.String(), want) + } + held, err := store.UserByEmail(t.Context(), "admin@example.com") + if err != nil || held.Role != "admin" { + t.Errorf("taken account = %+v, %v, want it to hold admin", held, err) + } +} + +func TestEnsureAccountsStopsAtAnAccountItCannotCreate(t *testing.T) { + t.Parallel() + + store := storeAt(t, migrated(t)) + accounts := demoAccounts() + accounts = []auth.Account{ + accounts[0], {Email: "weak@example.com", Name: "Maria Perez", Password: "short", Role: "editor"}, accounts[1], + } + var out strings.Builder + + err := auth.EnsureAccounts(t.Context(), store, accounts, &out) + + want := "account weak@example.com: gouncer: password shorter than 12 characters" + if !errors.Is(err, gouncer.ErrWeakPassword) || errorText(err) != want { + t.Errorf("EnsureAccounts() = %v, want the weak password refusal %q", err, want) + } + if want := "created admin@example.com\n"; out.String() != want { + t.Errorf("wrote %q, want %q", out.String(), want) + } + if _, err := store.UserByEmail(t.Context(), "editor@example.com"); !errors.Is(err, gouncer.ErrUserNotFound) { + t.Errorf("the account after the refusal = %v, want it never created", err) + } +} + +func TestEnsureAccountsFailsWhenItsLineCannotBeWritten(t *testing.T) { + t.Parallel() + + store := storeAt(t, migrated(t)) + + err := auth.EnsureAccounts(t.Context(), store, demoAccounts(), failingWriter{}) + + if errorText(err) != "the output is closed" { + t.Errorf("EnsureAccounts() = %v, want the write failure", err) + } + if _, err := store.UserByEmail(t.Context(), "editor@example.com"); !errors.Is(err, gouncer.ErrUserNotFound) { + t.Errorf("the second account = %v, want it never created after the failed line", err) + } +} + +// errorText returns the message of err, empty when it is nil. +func errorText(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/gonsole/auth/db_test.go b/gonsole/auth/db_test.go new file mode 100644 index 0000000..8e4451d --- /dev/null +++ b/gonsole/auth/db_test.go @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "testing" + + "github.com/gopherium/gouncer/authkit/postgres" + "github.com/gopherium/gouncer/authkit/postgres/testdb" + "github.com/jackc/pgx/v5/pgxpool" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/peterldowns/pgtestdb" +) + +// 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() +} + +// 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/schema.go b/gonsole/auth/schema.go new file mode 100644 index 0000000..d73389b --- /dev/null +++ b/gonsole/auth/schema.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "github.com/gopherium/gouncer/authkit/postgres" + + "github.com/gopherium/framework/gonsole" +) + +// Migration returns the step that applies gouncer's schema. +func Migration() gonsole.Step { + return gonsole.Step{Name: "accounts", Run: postgres.Migrate} +} diff --git a/gonsole/auth/schema_test.go b/gonsole/auth/schema_test.go new file mode 100644 index 0000000..24cccae --- /dev/null +++ b/gonsole/auth/schema_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "testing" + + "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) + } +} From dc45ba886588e731c8d8816e49f3b99dc89e3f6d Mon Sep 17 00:00:00 2001 From: SirLouen Date: Fri, 25 Sep 2026 13:56:26 +0200 Subject: [PATCH 03/10] feat(gonsole/auth): create an account under a known role --- gonsole/auth/config.go | 82 +++++++++++++++ gonsole/auth/createadmin.go | 52 ++++++++++ gonsole/auth/createadmin_test.go | 167 +++++++++++++++++++++++++++++++ gonsole/auth/helpers_test.go | 78 +++++++++++++++ 4 files changed, 379 insertions(+) create mode 100644 gonsole/auth/config.go create mode 100644 gonsole/auth/createadmin.go create mode 100644 gonsole/auth/createadmin_test.go create mode 100644 gonsole/auth/helpers_test.go diff --git a/gonsole/auth/config.go b/gonsole/auth/config.go new file mode 100644 index 0000000..d184298 --- /dev/null +++ b/gonsole/auth/config.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/gopherium/gouncer" + "github.com/gopherium/gouncer/authkit/postgres" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/gopherium/framework/gonsole" +) + +// Config is what a program hands its account commands. +type Config struct { + // Roles returns the program's role vocabulary. + Roles func(ctx context.Context, call gonsole.Call) (Roles, error) + // Capability names the capability every account write requires, empty for none. + Capability string +} + +// Roles is one program's role vocabulary. +type Roles struct { + // Known lists every role an account may hold. + Known []string + // Privileged lists the roles one enabled account must always keep. + Privileged gouncer.Roles +} + +// known returns a misuse naming the roles of roles when role is not one of them. +func known(roles Roles, role string) error { + if slices.Contains(roles.Known, role) { + return nil + } + return gonsole.Misuse(fmt.Errorf("unknown role %q, want %s", role, alternatives(roles.Known))) +} + +// alternatives joins names as a list read aloud, such as a, b or c. +func alternatives(names []string) string { + switch len(names) { + case 0: + return "a role the program declares" + case 1: + return names[0] + } + last := len(names) - 1 + return strings.Join(names[:last], ", ") + " or " + names[last] +} + +// needed is one flag a command requires and the placeholder its help page shows. +type needed struct { + flag string + value string +} + +// missing returns a misuse naming the first needed flag the call leaves blank. +func missing(command string, call gonsole.Call, flags ...needed) error { + for _, want := range flags { + if strings.TrimSpace(call.Flags[want.flag]) == "" { + return gonsole.Misuse(fmt.Errorf("%s wants -%s <%s>", command, want.flag, want.value)) + } + } + return nil +} + +// withStore runs use over the account store of the program's database and closes its pool after. +func withStore(ctx context.Context, call gonsole.Call, use func(store *postgres.UserStore) 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(postgres.NewUserStore(pool)) +} diff --git a/gonsole/auth/createadmin.go b/gonsole/auth/createadmin.go new file mode 100644 index 0000000..8787246 --- /dev/null +++ b/gonsole/auth/createadmin.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "flag" + + "github.com/gopherium/gouncer/authkit" + "github.com/gopherium/gouncer/authkit/postgres" + + "github.com/gopherium/framework/gonsole" +) + +// createName is the full name of the command that creates one account under a role. +const createName = "account:create-admin" + +// CreateAdmin returns account:create-admin, which creates one account under a known role. +func CreateAdmin(cfg Config) gonsole.Command { + return gonsole.Command{ + Name: createName, + Summary: "create an account under a role", + Migrates: true, + Flags: func(fs *flag.FlagSet) { + fs.String("email", "", "`address` of the new account") + fs.String("name", "", "display `name` of the new account") + fs.String("role", "", "`role` the new account starts under") + }, + Run: func(ctx context.Context, call gonsole.Call) error { + return create(ctx, call, cfg) + }, + } +} + +// create creates the account the call names under its role, once the role is known. +func create(ctx context.Context, call gonsole.Call, cfg Config) error { + err := missing(createName, call, needed{"email", "address"}, needed{"name", "name"}, needed{"role", "role"}) + if err != nil { + return err + } + roles, err := cfg.Roles(ctx, call) + if err != nil { + return err + } + if err := known(roles, call.Flags["role"]); err != nil { + return err + } + return withStore(ctx, call, func(store *postgres.UserStore) error { + return authkit.CreateAdmin(ctx, store, call.Flags["email"], call.Flags["name"], call.Flags["role"], + call.Stdin, call.Stdout) + }) +} diff --git a/gonsole/auth/createadmin_test.go b/gonsole/auth/createadmin_test.go new file mode 100644 index 0000000..e374086 --- /dev/null +++ b/gonsole/auth/createadmin_test.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "context" + "errors" + "slices" + "testing" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/testkit" +) + +// createPage is the help page of account:create-admin. +const createPage = `create an account under a role + +Usage: + myapp account:create-admin [flags] + +Flags: + -email address + address of the new account + -name name + display name of the new account + -role role + role the new account starts under +` + +// migratedLine is the line the account schema step writes before a command that asks for it. +const migratedLine = "migrated accounts\n" + +// 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} +} + +func TestCreateAdminCreatesAnAccountAfterTheSchema(t *testing.T) { + t.Parallel() + + address := empty(t) + var reads rolesRead + + got := testkit.Run(t, program(address, auth.CreateAdmin(config(&reads, ""))), 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 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) + } +} + +func TestCreateAdminRefusesALineItCannotRun(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + stdin string + code int + stdout string + stderr string + reads int + }{ + {"no address", []string{"account:create-admin", "-name", "Maria Perez", "-role", "admin"}, "", + gonsole.ExitMisused, "", migratedLine + "myapp: account:create-admin wants -email
\n\n" + createPage, 0}, + {"no name", []string{"account:create-admin", "-email", "maria.perez@example.com", "-role", "admin"}, "", + gonsole.ExitMisused, "", migratedLine + "myapp: account:create-admin wants -name \n\n" + createPage, 0}, + {"a blank name", append(creating("admin")[:3], "-name", " ", "-role", "admin"), "", gonsole.ExitMisused, "", + migratedLine + "myapp: account:create-admin wants -name \n\n" + createPage, 0}, + {"no role", creating("admin")[:5], "", gonsole.ExitMisused, "", + migratedLine + "myapp: account:create-admin wants -role \n\n" + createPage, 0}, + {"an unknown role", creating("owner"), "", gonsole.ExitMisused, "", + migratedLine + "myapp: unknown role \"owner\", want admin, editor or author\n\n" + createPage, 1}, + {"a padded role", creating(" admin"), "", gonsole.ExitMisused, "", + migratedLine + "myapp: unknown role \" admin\", want admin, editor or author\n\n" + createPage, 1}, + {"a taken address", []string{"account:create-admin", "-email", "admin@example.com", "-name", "Maria Perez", + "-role", "admin"}, demoPassword + "\n", gonsole.ExitFailed, "Password: ", + migratedLine + "myapp: gouncer: email already taken\n", 1}, + {"a weak password", creating("admin"), "short\n", gonsole.ExitFailed, "Password: ", + migratedLine + "myapp: gouncer: password shorter than 12 characters\n", 1}, + {"a request for help", []string{"account:create-admin", "-h"}, "", gonsole.ExitDone, createPage, "", 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var reads rolesRead + + got := testkit.Run(t, program(seeded(t), auth.CreateAdmin(config(&reads, ""))), tc.stdin, tc.args...) + + want := testkit.Result{Code: tc.code, Stdout: tc.stdout, Stderr: tc.stderr} + if got != want { + t.Errorf("Run() = %+v, want %+v", got, want) + } + if reads.count != tc.reads { + t.Errorf("roles read %d times, want %d", reads.count, tc.reads) + } + }) + } +} + +func TestCreateAdminTakesARoleThePluginsDeclare(t *testing.T) { + t.Parallel() + + 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 + }} + + got := testkit.Run(t, program(address, auth.CreateAdmin(cfg)), demoPassword+"\n", creating("superadmin")...) + + if got.Code != gonsole.ExitDone { + t.Fatalf("Run() = %+v, want a created account", got) + } + if held := account(t, storeAt(t, address), "maria.perez@example.com"); held.Role != "superadmin" { + t.Errorf("role = %q, want superadmin", held.Role) + } +} + +func TestCreateAdminFailsWhenTheRolesCannotBeRead(t *testing.T) { + t.Parallel() + + cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { + return auth.Roles{}, errors.New("the plugin table is locked") + }} + + got := testkit.Run(t, program(migrated(t), auth.CreateAdmin(cfg)), demoPassword+"\n", creating("admin")...) + + want := testkit.Result{Code: gonsole.ExitFailed, Stderr: migratedLine + "myapp: the plugin table is locked\n"} + if got != want { + t.Errorf("Run() = %+v, want %+v", got, want) + } +} + +func TestCreateAdminNamesNoRoleWhenTheProgramDeclaresNone(t *testing.T) { + t.Parallel() + + cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { return auth.Roles{}, nil }} + + got := testkit.Run(t, program(migrated(t), auth.CreateAdmin(cfg)), "", creating("admin")...) + + want := migratedLine + "myapp: unknown role \"admin\", want a role the program declares\n\n" + 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) { + t.Parallel() + + cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { + return auth.Roles{Known: []string{"admin"}}, nil + }} + + got := testkit.Run(t, program(migrated(t), auth.CreateAdmin(cfg)), "", creating("owner")...) + + 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) + } +} diff --git a/gonsole/auth/helpers_test.go b/gonsole/auth/helpers_test.go new file mode 100644 index 0000000..6544560 --- /dev/null +++ b/gonsole/auth/helpers_test.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "context" + "io" + "sync" + "testing" + + "github.com/gopherium/gouncer" + "github.com/gopherium/gouncer/authkit/postgres" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/testkit" +) + +// vocabulary is the role vocabulary the programs in these tests declare. +var vocabulary = auth.Roles{Known: []string{"admin", "editor", "author"}, Privileged: gouncer.Roles{"admin"}} + +// rolesRead counts how often a program's account commands read its roles. +type rolesRead struct { + mu sync.Mutex + count int +} + +// config returns a config that answers vocabulary, counts each read in reads and names capability. +func config(reads *rolesRead, capability string) auth.Config { + return auth.Config{ + Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { + reads.mu.Lock() + defer reads.mu.Unlock() + reads.count++ + return vocabulary, nil + }, + Capability: capability, + } +} + +// program returns a program called myapp over the database 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()}, + Commands: commands, + Authorize: func(context.Context, gonsole.Call, string) error { return nil }, + Record: func(context.Context, gonsole.Call, string) error { return nil }, + } +} + +// 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 { + t.Helper() + held, err := store.UserByEmail(t.Context(), email) + if err != nil { + t.Fatalf("UserByEmail(%s) = %v", email, err) + } + return held +} + +// seeded returns the address of a migrated database holding one enabled account per role of vocabulary. +func seeded(t *testing.T) string { + t.Helper() + address := migrated(t) + var accounts []auth.Account + for _, role := range vocabulary.Known { + accounts = append(accounts, auth.Account{ + Email: role + "@example.com", Name: role + " account", Password: demoPassword, Role: role, + }) + } + if err := auth.EnsureAccounts(t.Context(), storeAt(t, address), accounts, io.Discard); err != nil { + t.Fatalf("seeding: %v", err) + } + return address +} From 3e8c6cb1b77763f61255bddc9a142bb17ae2cd68 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Fri, 25 Sep 2026 13:56:27 +0200 Subject: [PATCH 04/10] feat(gonsole/auth): give a role to every account holding none --- gonsole/auth/grantrole.go | 92 +++++++++++++ gonsole/auth/grantrole_test.go | 229 +++++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 gonsole/auth/grantrole.go create mode 100644 gonsole/auth/grantrole_test.go diff --git a/gonsole/auth/grantrole.go b/gonsole/auth/grantrole.go new file mode 100644 index 0000000..8d59a46 --- /dev/null +++ b/gonsole/auth/grantrole.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "flag" + "fmt" + + "github.com/gopherium/gouncer/authkit/postgres" + + "github.com/gopherium/framework/gonsole" +) + +// grantName is the full name of the command that gives a role to every account holding none. +const grantName = "account:grant-role" + +// GrantRole returns account:grant-role, which gives a known role to every account holding none. +func GrantRole(cfg Config) gonsole.Command { + return gonsole.Command{ + Name: grantName, + Summary: "give a role to every account holding none", + Writes: true, + Migrates: true, + Capability: cfg.Capability, + Flags: func(fs *flag.FlagSet) { + fs.String("role", "", "`role` to give every account holding none") + }, + Run: func(ctx context.Context, call gonsole.Call) error { + return grant(ctx, call, cfg) + }, + } +} + +// grant gives the role the call names to every account holding none, once the role is known. +func grant(ctx context.Context, call gonsole.Call, cfg Config) error { + if err := missing(grantName, call, needed{"role", "role"}); err != nil { + return err + } + role := call.Flags["role"] + roles, err := cfg.Roles(ctx, call) + if err != nil { + return err + } + if err := known(roles, role); err != nil { + return err + } + return withStore(ctx, call, func(store *postgres.UserStore) error { + return grantIn(ctx, call, store, role) + }) +} + +// grantIn gives role to every account in store holding none, a dry run until the call applies. +func grantIn(ctx context.Context, call gonsole.Call, store *postgres.UserStore, role string) error { + if !call.Apply { + count, err := roleless(ctx, store) + if err != nil { + return err + } + _, err = fmt.Fprintf(call.Stdout, "would grant %s to %s\n", role, accounts(count)) + return err + } + granted, err := store.GrantRoleToRoleless(ctx, role) + if err != nil { + return err + } + _, err = fmt.Fprintf(call.Stdout, "granted %s to %s\n", role, accounts(granted)) + return err +} + +// roleless returns how many accounts in store hold no role. +func roleless(ctx context.Context, store *postgres.UserStore) (int64, error) { + users, err := store.ListUsers(ctx) + if err != nil { + return 0, err + } + var count int64 + for _, user := range users { + if user.Role == "" { + count++ + } + } + return count, nil +} + +// accounts names a count of accounts in plain English. +func accounts(count int64) string { + if count == 1 { + return "1 account" + } + return fmt.Sprintf("%d accounts", count) +} diff --git a/gonsole/auth/grantrole_test.go b/gonsole/auth/grantrole_test.go new file mode 100644 index 0000000..cae3e12 --- /dev/null +++ b/gonsole/auth/grantrole_test.go @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/gopherium/gouncer" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/testkit" +) + +// grantPage is the help page of account:grant-role in a program whose account writes name no capability. +const grantPage = `give a role to every account holding none + +Usage: + myapp account:grant-role [flags] + +Flags: + -role role + role to give every account holding none + -yes + apply the change, a dry run without it +` + +// 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. +func withRoleless(t *testing.T, count int) string { + t.Helper() + address := seeded(t) + store := storeAt(t, address) + for n := range count { + roleless, err := gouncer.NewUser(string(rune('a'+n))+"-roleless@example.com", "Maria Perez", demoPassword) + if err != nil { + t.Fatalf("NewUser() = %v", err) + } + if err := store.CreateUser(t.Context(), roleless); err != nil { + t.Fatalf("CreateUser() = %v", err) + } + } + return address +} + +func TestGrantRoleGivesTheRoleOnlyWithYes(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + roleless int + args []string + stdout string + stderr string + role string + }{ + {"a dry run", 2, []string{"account:grant-role", "-role", "editor"}, "would grant editor to 2 accounts\n", + dryRunNotice, ""}, + {"an applied grant", 2, []string{"account:grant-role", "-role", "editor", "-yes"}, + "granted editor to 2 accounts\n", migratedLine, "editor"}, + {"a dry run over one account", 1, []string{"account:grant-role", "-role", "author"}, + "would grant author to 1 account\n", dryRunNotice, ""}, + {"an applied grant over one account", 1, []string{"account:grant-role", "-role", "author", "-yes"}, + "granted author to 1 account\n", migratedLine, "author"}, + {"a grant with no account to take it", 0, []string{"account:grant-role", "-role", "editor", "-yes"}, + "granted editor to 0 accounts\n", migratedLine, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + address := withRoleless(t, tc.roleless) + var reads rolesRead + + got := testkit.Run(t, program(address, auth.GrantRole(config(&reads, ""))), "", tc.args...) + + want := testkit.Result{Code: gonsole.ExitDone, Stdout: tc.stdout, Stderr: tc.stderr} + 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 := 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) + } + }) + } +} + +func TestGrantRoleRefusesALineItCannotRun(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + stderr string + reads int + }{ + {"no role", []string{"account:grant-role"}, "myapp: account:grant-role wants -role \n\n" + grantPage, 0}, + {"an unknown role", []string{"account:grant-role", "-role", "owner"}, + "myapp: unknown role \"owner\", want admin, editor or author\n\n" + grantPage, 1}, + {"an unknown role with yes", []string{"account:grant-role", "-role", "owner", "-yes"}, + migratedLine + "myapp: unknown role \"owner\", want admin, editor or author\n\n" + grantPage, 1}, + {"a padded role", []string{"account:grant-role", "-role", " editor"}, + "myapp: unknown role \" editor\", want admin, editor or author\n\n" + grantPage, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + address := withRoleless(t, 1) + var reads rolesRead + + got := testkit.Run(t, program(address, auth.GrantRole(config(&reads, ""))), "", tc.args...) + + if want := (testkit.Result{Code: gonsole.ExitMisused, Stderr: tc.stderr}); got != want { + t.Errorf("Run() = %+v, want %+v", got, want) + } + if reads.count != tc.reads { + t.Errorf("roles read %d times, want %d", reads.count, tc.reads) + } + if held := account(t, storeAt(t, address), "a-roleless@example.com"); held.Role != "" { + t.Errorf("role = %q, want none granted", held.Role) + } + }) + } +} + +func TestGrantRoleDryRunFailsOverADatabaseNeverMigrated(t *testing.T) { + t.Parallel() + + var reads rolesRead + + got := testkit.Run(t, program(empty(t), 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) + } +} + +func TestGrantRoleCountsADisabledAccountHoldingNoRole(t *testing.T) { + t.Parallel() + + address := withRoleless(t, 1) + store := storeAt(t, address) + if err := store.SetUserDisabled(t.Context(), account(t, store, "a-roleless@example.com").ID, true); err != nil { + t.Fatalf("SetUserDisabled() = %v", err) + } + var reads rolesRead + p := program(address, auth.GrantRole(config(&reads, ""))) + + dryRun := testkit.Run(t, p, "", "account:grant-role", "-role", "editor") + applied := testkit.Run(t, p, "", "account:grant-role", "-role", "editor", "-yes") + + if dryRun.Stdout != "would grant editor to 1 account\n" || applied.Stdout != "granted editor to 1 account\n" { + t.Errorf("dry run %q then %q, want both to count the disabled account", dryRun.Stdout, applied.Stdout) + } +} + +func TestGrantRoleFailsWhenTheRolesCannotBeRead(t *testing.T) { + t.Parallel() + + cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { + return auth.Roles{}, errors.New("the plugin table is locked") + }} + + got := testkit.Run(t, program(withRoleless(t, 1), auth.GrantRole(cfg)), "", "account:grant-role", "-role", + "editor") + + if want := (testkit.Result{Code: gonsole.ExitFailed, Stderr: "myapp: the plugin table is locked\n"}); got != want { + t.Errorf("Run() = %+v, want %+v", got, want) + } +} + +func TestGrantRoleReportsAWriteTheStoreRefuses(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 + }} + 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()) + } + if held := account(t, storeAt(t, address), "a-roleless@example.com"); held.Role != "" { + t.Errorf("role = %q, want none granted", held.Role) + } +} + +func TestGrantRoleAsksTheProgramForItsCapability(t *testing.T) { + t.Parallel() + + var reads rolesRead + var asked []string + p := program(withRoleless(t, 1), auth.GrantRole(config(&reads, "manage_accounts"))) + p.Authorize = func(_ context.Context, call gonsole.Call, capability string) error { + asked = append(asked, call.Actor+" "+capability) + return nil + } + + missingActor := testkit.Run(t, p, "", "account:grant-role", "-role", "editor") + got := testkit.Run(t, p, "", "account:grant-role", "-role", "editor", "-as", "maria.perez@example.com") + + 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] != "maria.perez@example.com manage_accounts" { + t.Errorf("Run() = %+v, Authorize asked %q, want exit 0 and one manage_accounts check", got, asked) + } +} From 9b3e4782099fd6af879532e82f67df659adf998b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Fri, 25 Sep 2026 13:56:27 +0200 Subject: [PATCH 05/10] feat(gonsole/auth): list every account with its role and standing --- gonsole/auth/list.go | 75 +++++++++++++++++++ gonsole/auth/list_test.go | 154 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 gonsole/auth/list.go create mode 100644 gonsole/auth/list_test.go diff --git a/gonsole/auth/list.go b/gonsole/auth/list.go new file mode 100644 index 0000000..bb189bc --- /dev/null +++ b/gonsole/auth/list.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "cmp" + "context" + "fmt" + "io" + "text/tabwriter" + + "github.com/gopherium/gouncer" + "github.com/gopherium/gouncer/authkit/postgres" + + "github.com/gopherium/framework/gonsole" +) + +// List returns account:list, which lists every account with its role and standing. +func List(_ Config) gonsole.Command { + return gonsole.Command{ + Name: "account:list", + Summary: "list every account with its role", + JSON: true, + Run: func(ctx context.Context, call gonsole.Call) error { + return withStore(ctx, call, func(store *postgres.UserStore) error { + users, err := store.ListUsers(ctx) + if err != nil { + return err + } + if call.JSON { + return call.Encode(document(users)) + } + return table(call.Stdout, users) + }) + }, + } +} + +// listing is the document account:list answers. +type listing struct { + Accounts []listed `json:"accounts"` +} + +// listed is one account in the document account:list answers. +type listed struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Disabled bool `json:"disabled"` +} + +// document returns the document that lists users. +func document(users []gouncer.User) listing { + answer := listing{Accounts: make([]listed, 0, len(users))} + for _, user := range users { + answer.Accounts = append(answer.Accounts, listed{ + ID: user.ID.String(), Email: user.Email, Name: user.Name, Role: user.Role, Disabled: user.Disabled, + }) + } + return answer +} + +// table writes one aligned line per user to w: the address, the role or a dash, and the standing. +func table(w io.Writer, users []gouncer.User) error { + aligned := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, user := range users { + standing := "enabled" + if user.Disabled { + standing = "disabled" + } + _, _ = fmt.Fprintf(aligned, "%s\t%s\t%s\n", user.Email, cmp.Or(user.Role, "-"), standing) + } + return aligned.Flush() +} diff --git a/gonsole/auth/list_test.go b/gonsole/auth/list_test.go new file mode 100644 index 0000000..bb75a9f --- /dev/null +++ b/gonsole/auth/list_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "strings" + "testing" + + "github.com/gopherium/gouncer" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "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. +func listed(t *testing.T) string { + t.Helper() + address := seeded(t) + store := storeAt(t, address) + roleless, err := gouncer.NewUser("roleless@example.com", "account without a role", demoPassword) + if err != nil { + t.Fatalf("NewUser() = %v", err) + } + if err := store.CreateUser(t.Context(), roleless); err != nil { + t.Fatalf("CreateUser() = %v", err) + } + if err := store.SetUserDisabled(t.Context(), account(t, store, "editor@example.com").ID, true); err != nil { + t.Fatalf("SetUserDisabled() = %v", err) + } + return address +} + +func TestListPrintsEveryAccountWithItsRoleAndStanding(t *testing.T) { + t.Parallel() + + var reads rolesRead + + got := testkit.Run(t, program(listed(t), auth.List(config(&reads, "manage_accounts"))), "", "account:list") + + want := `roleless@example.com - enabled +admin@example.com admin enabled +author@example.com author enabled +editor@example.com editor disabled +` + if got != (testkit.Result{Code: gonsole.ExitDone, Stdout: want}) { + t.Errorf("Run() = %+v, want %q", got, want) + } + if reads.count != 0 { + t.Errorf("roles read %d times, want none for a listing", reads.count) + } +} + +func TestListAnswersOneDocument(t *testing.T) { + t.Parallel() + + address := listed(t) + var reads rolesRead + + 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) + } + want := `{ + "accounts": [ + { + "id": "id of roleless@example.com", + "email": "roleless@example.com", + "name": "account without a role", + "role": "", + "disabled": false + }, + { + "id": "id of admin@example.com", + "email": "admin@example.com", + "name": "admin account", + "role": "admin", + "disabled": false + }, + { + "id": "id of author@example.com", + "email": "author@example.com", + "name": "author account", + "role": "author", + "disabled": false + }, + { + "id": "id of editor@example.com", + "email": "editor@example.com", + "name": "editor account", + "role": "editor", + "disabled": true + } + ] +} +` + 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) + } +} + +func TestListOfNoAccount(t *testing.T) { + t.Parallel() + + var reads rolesRead + p := program(migrated(t), auth.List(config(&reads, ""))) + + text := testkit.Run(t, p, "", "account:list") + document := testkit.Run(t, p, "", "account:list", "-json") + + if text != (testkit.Result{Code: gonsole.ExitDone}) { + t.Errorf("Run() = %+v, want nothing printed", text) + } + if want := "{\n \"accounts\": []\n}\n"; document != (testkit.Result{Code: gonsole.ExitDone, Stdout: want}) { + t.Errorf("Run() with -json = %+v, want %q", document, want) + } +} + +func TestListFailsWithoutAUsableDatabase(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + env map[string]string + prefix string + }{ + {"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: "}, + } + 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) + } + + 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) + } + }) + } +} From a9c9cfd93fbe136f10e0ff36f84915ea56879952 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Fri, 25 Sep 2026 13:56:28 +0200 Subject: [PATCH 06/10] feat(gonsole/auth): set one account's role --- gonsole/auth/role.go | 69 +++++++++++ gonsole/auth/role_internal_test.go | 27 +++++ gonsole/auth/role_test.go | 185 +++++++++++++++++++++++++++++ 3 files changed, 281 insertions(+) create mode 100644 gonsole/auth/role.go create mode 100644 gonsole/auth/role_internal_test.go create mode 100644 gonsole/auth/role_test.go diff --git a/gonsole/auth/role.go b/gonsole/auth/role.go new file mode 100644 index 0000000..c4e7554 --- /dev/null +++ b/gonsole/auth/role.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/gopherium/gouncer" + "github.com/gopherium/gouncer/authkit/postgres" + + "github.com/gopherium/framework/gonsole" +) + +// SetRole returns account:role, which sets one account's role. +func SetRole(cfg Config) gonsole.Command { + return gonsole.Command{ + Name: "account:role", + Summary: "set one account's role", + Args: []string{"email", "role"}, + Writes: true, + Capability: cfg.Capability, + Run: func(ctx context.Context, call gonsole.Call) error { + return setRole(ctx, call, cfg) + }, + } +} + +// setRole gives the account the call names the role it names, once the role is known. +func setRole(ctx context.Context, call gonsole.Call, cfg Config) error { + email, role := address(call.Args[0]), call.Args[1] + roles, err := cfg.Roles(ctx, call) + if err != nil { + return err + } + if err := known(roles, role); err != nil { + return err + } + return withStore(ctx, call, func(store *postgres.UserStore) error { + held, err := store.UserByEmail(ctx, email) + if err != nil { + return err + } + if !call.Apply { + _, err := fmt.Fprintf(call.Stdout, "would set %s to %s\n", held.Email, role) + return err + } + if err := store.SetUserRole(ctx, held.ID, role, roles.Privileged); err != nil { + return uncovered(held.Email, err) + } + _, err = fmt.Fprintf(call.Stdout, "set %s to %s\n", held.Email, role) + return err + }) +} + +// address returns typed as gouncer stores an address, trimmed and in lower case. +func address(typed string) string { + return strings.ToLower(strings.TrimSpace(typed)) +} + +// uncovered returns err, worded for an operator when it refuses to remove the last privileged account. +func uncovered(email string, err error) error { + if errors.Is(err, gouncer.ErrLastPrivileged) { + return fmt.Errorf("%s is the last enabled privileged account", email) + } + return err +} diff --git a/gonsole/auth/role_internal_test.go b/gonsole/auth/role_internal_test.go new file mode 100644 index 0000000..ed7860f --- /dev/null +++ b/gonsole/auth/role_internal_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "errors" + "fmt" + "testing" + + "github.com/gopherium/gouncer" +) + +func TestUncoveredWordsOnlyTheLastPrivilegedRefusal(t *testing.T) { + t.Parallel() + + failed := errors.New("postgres: set user role: connection reset") + + last := uncovered("admin@example.com", fmt.Errorf("postgres: %w", gouncer.ErrLastPrivileged)) + other := uncovered("admin@example.com", failed) + + if want := "admin@example.com is the last enabled privileged account"; last == nil || last.Error() != want { + t.Errorf("uncovered() = %v, want %q", last, want) + } + if !errors.Is(other, failed) { + t.Errorf("uncovered() = %v, want the other failure unchanged", other) + } +} diff --git a/gonsole/auth/role_test.go b/gonsole/auth/role_test.go new file mode 100644 index 0000000..98882e9 --- /dev/null +++ b/gonsole/auth/role_test.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/gopherium/gouncer" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/testkit" +) + +// rolePage is the help page of account:role in a program whose account writes name no capability. +const rolePage = `set one account's role + +Usage: + myapp account:role [flags] + +Flags: + -yes + apply the change, a dry run without it +` + +func TestSetRoleSetsTheRoleOnlyWithYes(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + stdout string + stderr string + role string + }{ + {"a dry run", []string{"account:role", "editor@example.com", "author"}, + "would set editor@example.com to author\n", dryRunNotice, "editor"}, + {"an applied change", []string{"account:role", "editor@example.com", "author", "-yes"}, + "set editor@example.com to author\n", "", "author"}, + {"an address typed in capitals between spaces", []string{"account:role", " Editor@Example.com ", "author", "-yes"}, + "set editor@example.com to author\n", "", "author"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + address := seeded(t) + var reads rolesRead + + got := testkit.Run(t, program(address, auth.SetRole(config(&reads, ""))), "", tc.args...) + + if want := (testkit.Result{Code: gonsole.ExitDone, Stdout: tc.stdout, Stderr: tc.stderr}); got != want { + t.Fatalf("Run() = %+v, want %+v", got, want) + } + if held := account(t, storeAt(t, address), "editor@example.com"); held.Role != tc.role { + t.Errorf("role = %q, want %q", held.Role, tc.role) + } + }) + } +} + +func TestSetRoleRefusesALineItCannotRun(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + code int + stdout string + stderr string + reads int + }{ + {"an unknown role", []string{"account:role", "editor@example.com", "owner"}, gonsole.ExitMisused, "", + "myapp: unknown role \"owner\", want admin, editor or author\n\n" + rolePage, 1}, + {"a role in capitals", []string{"account:role", "editor@example.com", "Author"}, gonsole.ExitMisused, "", + "myapp: unknown role \"Author\", want admin, editor or author\n\n" + rolePage, 1}, + {"no role", []string{"account:role", "editor@example.com"}, gonsole.ExitMisused, "", + "myapp: account:role wants \n\n" + rolePage, 0}, + {"an unknown address", []string{"account:role", "nobody@example.com", "author", "-yes"}, gonsole.ExitFailed, + "", "myapp: gouncer: user not found\n", 1}, + {"an unknown address in a dry run", []string{"account:role", "nobody@example.com", "author"}, + gonsole.ExitFailed, "", "myapp: gouncer: user not found\n", 1}, + {"the last privileged account", []string{"account:role", "admin@example.com", "editor", "-yes"}, + gonsole.ExitFailed, "", "myapp: admin@example.com is the last enabled privileged account\n", 1}, + {"a request for help", []string{"account:role", "-h"}, gonsole.ExitDone, rolePage, "", 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + address := seeded(t) + var reads rolesRead + + got := testkit.Run(t, program(address, auth.SetRole(config(&reads, ""))), "", tc.args...) + + if want := (testkit.Result{Code: tc.code, Stdout: tc.stdout, Stderr: tc.stderr}); got != want { + t.Errorf("Run() = %+v, want %+v", got, want) + } + if reads.count != tc.reads { + t.Errorf("roles read %d times, want %d", reads.count, tc.reads) + } + if held := account(t, storeAt(t, address), "admin@example.com"); held.Role != "admin" { + t.Errorf("admin role = %q, want admin kept", held.Role) + } + }) + } +} + +func TestSetRoleDemotesAPrivilegedAccountThatIsNotTheLast(t *testing.T) { + t.Parallel() + + address := seeded(t) + var reads rolesRead + p := program(address, auth.SetRole(config(&reads, ""))) + promoted := testkit.Run(t, p, "", "account:role", "editor@example.com", "admin", "-yes") + + got := testkit.Run(t, p, "", "account:role", "admin@example.com", "author", "-yes") + + if promoted.Code != gonsole.ExitDone || got.Code != gonsole.ExitDone { + t.Fatalf("Run() = %+v, then %+v, want both applied", promoted, got) + } + if held := account(t, storeAt(t, address), "admin@example.com"); held.Role != "author" { + t.Errorf("role = %q, want author", held.Role) + } +} + +func TestSetRoleKeepsTheLastAccountUnderAPrivilegedRoleAPluginDeclares(t *testing.T) { + t.Parallel() + + address := seeded(t) + store := storeAt(t, address) + if err := store.SetUserRole(t.Context(), account(t, store, "author@example.com").ID, "superadmin", + nil); err != nil { + t.Fatalf("SetUserRole() = %v", err) + } + cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { + return auth.Roles{ + Known: []string{"admin", "editor", "author", "superadmin"}, + Privileged: gouncer.Roles{"admin", "superadmin"}, + }, nil + }} + + got := testkit.Run(t, program(address, auth.SetRole(cfg)), "", "account:role", "admin@example.com", "author", + "-yes") + last := testkit.Run(t, program(address, auth.SetRole(cfg)), "", "account:role", "author@example.com", "editor", + "-yes") + + if got.Code != gonsole.ExitDone { + t.Errorf("demoting admin beside a superadmin = %+v, want it applied", got) + } + if want := "myapp: author@example.com is the last enabled privileged account\n"; last.Code != gonsole.ExitFailed || + last.Stderr != want { + t.Errorf("demoting the last superadmin = %+v, want exit 1 and %q", last, want) + } +} + +func TestSetRoleFailsWhenTheRolesCannotBeRead(t *testing.T) { + t.Parallel() + + cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { + return auth.Roles{}, errors.New("the plugin table is locked") + }} + + got := testkit.Run(t, program(seeded(t), auth.SetRole(cfg)), "", "account:role", "editor@example.com", "author") + + if want := (testkit.Result{Code: gonsole.ExitFailed, Stderr: "myapp: the plugin table is locked\n"}); got != want { + t.Errorf("Run() = %+v, want %+v", got, want) + } +} + +func TestSetRoleAsksForTheActingAccountTheProgramNames(t *testing.T) { + t.Parallel() + + var reads rolesRead + + got := testkit.Run(t, program(seeded(t), auth.SetRole(config(&reads, "manage_accounts"))), "", "account:role", + "editor@example.com", "author") + + if got.Code != gonsole.ExitMisused || !strings.HasPrefix(got.Stderr, "myapp: account:role wants -as \n") { + t.Errorf("Run() = %+v, want exit 2 asking for -as", got) + } +} From 3d92262088bcca5f97b380259a37187c5e42a868 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Fri, 25 Sep 2026 13:56:28 +0200 Subject: [PATCH 07/10] feat(gonsole/auth): disable and enable one account --- gonsole/auth/standing.go | 60 ++++++++++++ gonsole/auth/standing_test.go | 170 ++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 gonsole/auth/standing.go create mode 100644 gonsole/auth/standing_test.go diff --git a/gonsole/auth/standing.go b/gonsole/auth/standing.go new file mode 100644 index 0000000..21be256 --- /dev/null +++ b/gonsole/auth/standing.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "fmt" + + "github.com/gopherium/gouncer/authkit/postgres" + + "github.com/gopherium/framework/gonsole" +) + +// Disable returns account:disable, which disables one account. +func Disable(cfg Config) gonsole.Command { + return standing(cfg, "account:disable", "disable one account", "disable", true) +} + +// Enable returns account:enable, which enables one disabled account. +func Enable(cfg Config) gonsole.Command { + return standing(cfg, "account:enable", "enable one disabled account", "enable", false) +} + +// standing returns the command called name that sets whether one account is disabled, verb naming the change. +func standing(cfg Config, name, summary, verb string, disabled bool) gonsole.Command { + return gonsole.Command{ + Name: name, + Summary: summary, + Args: []string{"email"}, + Writes: true, + Capability: cfg.Capability, + Run: func(ctx context.Context, call gonsole.Call) error { + return setStanding(ctx, call, cfg, verb, disabled) + }, + } +} + +// setStanding sets whether the account the call names is disabled, never leaving no privileged account enabled. +func setStanding(ctx context.Context, call gonsole.Call, cfg Config, verb string, disabled bool) error { + email := address(call.Args[0]) + roles, err := cfg.Roles(ctx, call) + if err != nil { + return err + } + return withStore(ctx, call, func(store *postgres.UserStore) error { + held, err := store.UserByEmail(ctx, email) + if err != nil { + return err + } + if !call.Apply { + _, err := fmt.Fprintf(call.Stdout, "would %s %s\n", verb, held.Email) + return err + } + if err := store.SetUserDisabledUnderCover(ctx, held.ID, disabled, roles.Privileged); err != nil { + return uncovered(held.Email, err) + } + _, err = fmt.Fprintf(call.Stdout, "%sd %s\n", verb, held.Email) + return err + }) +} diff --git a/gonsole/auth/standing_test.go b/gonsole/auth/standing_test.go new file mode 100644 index 0000000..9134ea1 --- /dev/null +++ b/gonsole/auth/standing_test.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/testkit" +) + +// disablePage is the help page of account:disable in a program whose account writes name no capability. +const disablePage = `disable one account + +Usage: + myapp account:disable [flags] + +Flags: + -yes + apply the change, a dry run without it +` + +// enablePage is the help page of account:enable in a program whose account writes name no capability. +const enablePage = `enable one disabled account + +Usage: + myapp account:enable [flags] + +Flags: + -yes + apply the change, a dry run without it +` + +// withDisabledEditor returns the address of a seeded database whose editor is disabled. +func withDisabledEditor(t *testing.T) string { + t.Helper() + address := seeded(t) + store := storeAt(t, address) + if err := store.SetUserDisabled(t.Context(), account(t, store, "editor@example.com").ID, true); err != nil { + t.Fatalf("SetUserDisabled() = %v", err) + } + return address +} + +// standingCommands returns account:disable and account:enable over cfg. +func standingCommands(cfg auth.Config) []gonsole.Command { + return []gonsole.Command{auth.Disable(cfg), auth.Enable(cfg)} +} + +func TestDisableAndEnableChangeTheStandingOnlyWithYes(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + disabled bool + args []string + stdout string + stderr string + after bool + }{ + {"a dry run of a disable", false, []string{"account:disable", "editor@example.com"}, + "would disable editor@example.com\n", dryRunNotice, false}, + {"an applied disable", false, []string{"account:disable", "editor@example.com", "-yes"}, + "disabled editor@example.com\n", "", true}, + {"a disable of an address typed in capitals", false, []string{"account:disable", " Editor@Example.com ", "-yes"}, + "disabled editor@example.com\n", "", true}, + {"a dry run of an enable", true, []string{"account:enable", "editor@example.com"}, + "would enable editor@example.com\n", dryRunNotice, true}, + {"an applied enable", true, []string{"account:enable", "editor@example.com", "-yes"}, + "enabled editor@example.com\n", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + address := seeded(t) + if tc.disabled { + address = withDisabledEditor(t) + } + var reads rolesRead + + got := testkit.Run(t, program(address, standingCommands(config(&reads, ""))...), "", tc.args...) + + if want := (testkit.Result{Code: gonsole.ExitDone, Stdout: tc.stdout, Stderr: tc.stderr}); got != want { + t.Fatalf("Run() = %+v, want %+v", got, want) + } + if held := account(t, storeAt(t, address), "editor@example.com"); held.Disabled != tc.after { + t.Errorf("disabled = %t, want %t", held.Disabled, tc.after) + } + }) + } +} + +func TestDisableAndEnableRefuseALineTheyCannotRun(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + code int + stdout string + stderr string + }{ + {"the last privileged account", []string{"account:disable", "admin@example.com", "-yes"}, gonsole.ExitFailed, + "", "myapp: admin@example.com is the last enabled privileged account\n"}, + {"an unknown address to disable", []string{"account:disable", "nobody@example.com"}, gonsole.ExitFailed, "", + "myapp: gouncer: user not found\n"}, + {"an unknown address to enable", []string{"account:enable", "nobody@example.com", "-yes"}, gonsole.ExitFailed, + "", "myapp: gouncer: user not found\n"}, + {"no address", []string{"account:disable"}, gonsole.ExitMisused, "", + "myapp: account:disable wants \n\n" + disablePage}, + {"a request for help to disable", []string{"account:disable", "-h"}, gonsole.ExitDone, disablePage, ""}, + {"a request for help to enable", []string{"account:enable", "-h"}, gonsole.ExitDone, enablePage, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + address := seeded(t) + var reads rolesRead + + got := testkit.Run(t, program(address, standingCommands(config(&reads, ""))...), "", tc.args...) + + if want := (testkit.Result{Code: tc.code, Stdout: tc.stdout, Stderr: tc.stderr}); got != want { + t.Errorf("Run() = %+v, want %+v", got, want) + } + if held := account(t, storeAt(t, address), "admin@example.com"); held.Disabled { + t.Error("the admin account is disabled, want it kept enabled") + } + }) + } +} + +func TestDisableFailsWhenTheRolesCannotBeRead(t *testing.T) { + t.Parallel() + + cfg := auth.Config{Roles: func(context.Context, gonsole.Call) (auth.Roles, error) { + return auth.Roles{}, errors.New("the plugin table is locked") + }} + + got := testkit.Run(t, program(seeded(t), standingCommands(cfg)...), "", "account:disable", "editor@example.com") + + if want := (testkit.Result{Code: gonsole.ExitFailed, Stderr: "myapp: the plugin table is locked\n"}); got != want { + t.Errorf("Run() = %+v, want %+v", got, want) + } +} + +func TestDisableAndEnableAskForTheActingAccountTheProgramNames(t *testing.T) { + t.Parallel() + + for _, command := range []string{"account:disable", "account:enable"} { + t.Run(command, func(t *testing.T) { + t.Parallel() + + var reads rolesRead + + got := testkit.Run(t, program(seeded(t), standingCommands(config(&reads, "manage_accounts"))...), "", + command, "editor@example.com") + + if want := "myapp: " + command + " wants -as \n"; got.Code != gonsole.ExitMisused || + !strings.HasPrefix(got.Stderr, want) { + t.Errorf("Run() = %+v, want exit 2 opening with %q", got, want) + } + }) + } +} From 406a592b2dd733a908c3272aa41c97ef6904e954 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Fri, 25 Sep 2026 13:56:29 +0200 Subject: [PATCH 08/10] feat(gonsole/auth): offer every account command in order --- gonsole/auth/commands.go | 12 ++++++ gonsole/auth/commands_test.go | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 gonsole/auth/commands.go create mode 100644 gonsole/auth/commands_test.go diff --git a/gonsole/auth/commands.go b/gonsole/auth/commands.go new file mode 100644 index 0000000..84c2301 --- /dev/null +++ b/gonsole/auth/commands.go @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "github.com/gopherium/framework/gonsole" +) + +// Commands returns every account command over cfg, in the order they are declared. +func Commands(cfg Config) []gonsole.Command { + return []gonsole.Command{CreateAdmin(cfg), GrantRole(cfg), List(cfg), SetRole(cfg), Disable(cfg), Enable(cfg)} +} diff --git a/gonsole/auth/commands_test.go b/gonsole/auth/commands_test.go new file mode 100644 index 0000000..24062f8 --- /dev/null +++ b/gonsole/auth/commands_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "slices" + "testing" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/testkit" +) + +// intro is the paragraph every listing prints before its commands. +const intro = "Every command answers -h. A command that offers -json answers one JSON document. " + + "A command that offers -yes is a dry run until -yes.\n" + +// accountListing is the listing of a program whose only commands are the account commands. +const accountListing = `myapp + +Usage: + myapp [flags] [arguments] + +` + intro + ` +Available commands: + check check every setting, every plugin and every command name + help print the help of one command + list list every command + migrate apply every schema step + version print the version + account + account:create-admin create an account under a role + account:disable disable one account + account:enable enable one disabled account + account:grant-role give a role to every account holding none + account:list list every account with its role + account:role set one account's role +` + +func TestCommandsOffersEveryAccountCommandInOrder(t *testing.T) { + t.Parallel() + + var reads rolesRead + + commands := auth.Commands(config(&reads, "manage_accounts")) + + var got []string + for _, cmd := range commands { + got = append(got, cmd.Name+" "+cmd.Capability) + } + want := []string{ + "account:create-admin ", "account:grant-role manage_accounts", "account:list ", + "account:role manage_accounts", "account:disable manage_accounts", "account:enable manage_accounts", + } + if !slices.Equal(got, want) { + t.Errorf("commands = %q, want %q", got, want) + } + if err := program(migrated(t), commands...).Check(gonsole.Loaded{}); err != nil { + t.Errorf("Check() = %v, want nil", err) + } +} + +func TestCommandsListUnderTheAccountNamespace(t *testing.T) { + t.Parallel() + + var reads rolesRead + + got := testkit.Run(t, program(migrated(t), auth.Commands(config(&reads, ""))...), "", "list") + + if got != (testkit.Result{Code: gonsole.ExitDone, Stdout: accountListing}) { + t.Errorf("Run() = %+v, want %q", got, accountListing) + } +} From 4a051501000ad0526cb5f1a726c9ab80f068c355 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Fri, 25 Sep 2026 13:56:29 +0200 Subject: [PATCH 09/10] ci: test, lint and scan the gonsole/auth module --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec0471d..a6462a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,40 @@ jobs: - run: go test -race -covermode=atomic -coverprofile=cover.out ./... - run: go vet ./... + test-postgres: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + module: ["gonsole/auth"] + defaults: + run: + working-directory: ${{ matrix.module }} + services: + postgres: + image: postgres:18 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5434:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 2s + --health-timeout 2s + --health-retries 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: ${{ matrix.module }}/go.mod + cache-dependency-path: | + ${{ matrix.module }}/go.mod + ${{ matrix.module }}/go.sum + - run: go test -race -covermode=atomic -coverprofile=cover.out ./... + - run: go vet ./... + gottext: runs-on: ubuntu-latest defaults: @@ -61,7 +95,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["mailkit", "gonsole"] + module: ["mailkit", "gonsole", "gonsole/auth"] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -83,7 +117,7 @@ jobs: strategy: fail-fast: false matrix: - module: ["mailkit", "gonsole"] + module: ["mailkit", "gonsole", "gonsole/auth"] defaults: run: working-directory: ${{ matrix.module }} From f7def250c7a85867afab996c9a282e1075e4e5d1 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Fri, 25 Sep 2026 13:56:30 +0200 Subject: [PATCH 10/10] docs(gonsole/auth): list the module and its unreleased changes --- README.md | 2 ++ gonsole/auth/CHANGELOG.md | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 gonsole/auth/CHANGELOG.md diff --git a/README.md b/README.md index 739acbf..cd6a837 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ need and ignore the rest. - [`gonsole`](gonsole/) runs the command line of a Go program built from core commands, settings and compiled plugins. +- [`gonsole/auth`](gonsole/auth/) offers the account commands of a program + whose accounts live in gouncer's Postgres store. - [`gottext`](gottext/) reads, writes and syncs gettext catalogs for TypeScript applications, published to npm as `@gopherium/gottext`. - [`mailkit`](mailkit/) renders mail from template files and sends it diff --git a/gonsole/auth/CHANGELOG.md b/gonsole/auth/CHANGELOG.md new file mode 100644 index 0000000..94265eb --- /dev/null +++ b/gonsole/auth/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to the `gonsole/auth` module are documented in this +file. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the +module follows [Semantic Versioning](https://semver.org/). While at +v0.x, minor releases may contain breaking changes. + +Releases of this module are tagged `gonsole/auth/vX.Y.Z`. + +## [Unreleased] + +### Added + +- `Migration`, the schema step that applies gouncer's account schema. +- `Account` and `EnsureAccounts`, creating each demo account unless its address is taken. +- `Config` and `Roles`, the capability and the role vocabulary a program hands its account commands. +- `account:create-admin`, creating one account under a known role, its password read from stdin. +- `account:grant-role`, giving a known role to every account holding none, a dry run until `-yes`. +- `account:list`, listing every account with its role and standing, or one JSON document. +- `account:role`, setting one account's role, a dry run until `-yes`. +- `account:disable` and `account:enable`, changing whether one account may log in, each a dry run until `-yes`. +- The last enabled account under a privileged role is never demoted or disabled. +- Every command finds an account by its address trimmed and in lower case. +- `Commands`, every account command in the order it is declared.