Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion gonsole/auth/compose.yaml → dbkit/postgres/compose.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: gonsole-auth
name: dbkit-postgres

services:
postgres:
Expand Down
26 changes: 19 additions & 7 deletions gonsole/auth/.golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +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/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$
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/google/uuid$
forbidigo:
analyze-types: true
forbid:
Expand Down
11 changes: 11 additions & 0 deletions gonsole/auth/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 32 additions & 31 deletions gonsole/auth/authorize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
}

Expand All @@ -54,19 +57,19 @@ func TestAuthorizeRefusesAnActorItCannotLetThrough(t *testing.T) {

tests := []struct {
name string
setup []string
setup []change
actor string
want string
}{
{"a role lacking the capability", nil, "editor@example.com",
"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{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"},
}
for _, tt := range tests {
Expand All @@ -79,7 +82,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 {
Expand All @@ -105,7 +108,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", " ")

Expand All @@ -124,7 +127,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)
}
}
Expand All @@ -138,7 +141,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)
}
}
Expand All @@ -160,32 +163,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)
}
73 changes: 66 additions & 7 deletions gonsole/auth/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
const unreachable = "memory://unreachable"

// The errors a failing builder, record store or release answers.
var (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -143,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()

Expand Down
26 changes: 6 additions & 20 deletions gonsole/auth/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Loading
Loading