From 24b16b1f1024deed1be2995c230dc46c9a1509be Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 7 Oct 2026 13:35:59 +0200 Subject: [PATCH] fix: open Postgres for one-shot console commands --- console/api_tokens.go | 20 ++++--- console/require_password_change.go | 43 +++++++-------- console_test.go | 87 ++++++++++++++++++++++++++---- plugin.go | 22 +++++++- 4 files changed, 126 insertions(+), 46 deletions(-) diff --git a/console/api_tokens.go b/console/api_tokens.go index 9bed9bd..193482b 100644 --- a/console/api_tokens.go +++ b/console/api_tokens.go @@ -16,16 +16,14 @@ import ( // APITokenCommands are the application-wide token create/list/revoke // commands. Domain plugins deliberately do not mint their own credentials. -func APITokenCommands(app *backpack.App) []bonfire.Command { - withDB := func(ctx context.Context, fn func(*gorm.DB) error) error { - if app == nil { - return fmt.Errorf("golem15.user: app is nil") - } - db, ok := app.Lookup[*gorm.DB]() - if !ok || db == nil { +// withDB is the plugin's database accessor so the commands work both during +// serve and as one-shot CLI runs that open Postgres from config. +func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm.DB) error) error) []bonfire.Command { + open := func(ctx context.Context, fn func(*gorm.DB) error) error { + if withDB == nil { return fmt.Errorf("golem15.user: database is not configured") } - return fn(db.WithContext(ctx)) + return withDB(ctx, fn) } return []bonfire.Command{ { @@ -54,7 +52,7 @@ func APITokenCommands(app *backpack.App) []bonfire.Command { } expiresAt = &parsed } - return withDB(ctx, func(db *gorm.DB) error { + return open(ctx, func(db *gorm.DB) error { user, err := classes.LookupByEmail(ctx, db, strings.TrimSpace(email)) if err != nil { return err @@ -83,7 +81,7 @@ func APITokenCommands(app *backpack.App) []bonfire.Command { { Name: "user:token:list", Description: "List API tokens", Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error { - return withDB(ctx, func(db *gorm.DB) error { + return open(ctx, func(db *gorm.DB) error { var tokens []models.APIToken if err := db.Order("id").Find(&tokens).Error; err != nil { return fmt.Errorf("user:token:list: %w", err) @@ -116,7 +114,7 @@ func APITokenCommands(app *backpack.App) []bonfire.Command { if err != nil || id == 0 { return fmt.Errorf("user:token:revoke: %q is not a token id", raw) } - return withDB(ctx, func(db *gorm.DB) error { + return open(ctx, func(db *gorm.DB) error { res := db.Model(&models.APIToken{}).Where("id = ? AND revoked_at IS NULL", id).UpdateColumn("revoked_at", time.Now()) if res.Error != nil { return fmt.Errorf("user:token:revoke: %w", res.Error) diff --git a/console/require_password_change.go b/console/require_password_change.go index 7994e47..b4a3fa8 100644 --- a/console/require_password_change.go +++ b/console/require_password_change.go @@ -7,13 +7,12 @@ import ( "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/models" - "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "gorm.io/gorm" ) // RequirePasswordChange forces the named account to set a new password on next login. -func RequirePasswordChange(app *backpack.App) bonfire.Command { +func RequirePasswordChange(withDB func(context.Context, func(*gorm.DB) error) error) bonfire.Command { return bonfire.Command{ Name: "user:require-password-change", Description: "Force a user to change their password on next login (D-21)", @@ -28,29 +27,27 @@ func RequirePasswordChange(app *backpack.App) bonfire.Command { if !ok || email == "" { return fmt.Errorf("email is required") } - if app == nil { - return fmt.Errorf("user:require-password-change: app is nil") - } - gdb, ok := app.Lookup[*gorm.DB]() - if !ok || gdb == nil { + if withDB == nil { return fmt.Errorf("user:require-password-change: database is not configured") } - user, err := classes.LookupByEmail(ctx, gdb, email) - if err != nil { - return err - } - if user == nil { - return fmt.Errorf("user %s not found", email) - } - res := gdb.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).Update("must_change_password", true) - if res.Error != nil { - return res.Error - } - if res.RowsAffected != 1 { - return fmt.Errorf("user %s was not updated", email) - } - out.Success("Password change required for " + user.Email) - return nil + return withDB(ctx, func(gdb *gorm.DB) error { + user, err := classes.LookupByEmail(ctx, gdb, email) + if err != nil { + return err + } + if user == nil { + return fmt.Errorf("user %s not found", email) + } + res := gdb.WithContext(ctx).Model(&models.User{}).Where("id = ?", user.ID).Update("must_change_password", true) + if res.Error != nil { + return res.Error + } + if res.RowsAffected != 1 { + return fmt.Errorf("user %s was not updated", email) + } + out.Success("Password change required for " + user.Email) + return nil + }) }, } } diff --git a/console_test.go b/console_test.go index d1bbaa4..53454ca 100644 --- a/console_test.go +++ b/console_test.go @@ -7,8 +7,10 @@ import ( "testing" "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" ) func TestRequirePasswordChange(t *testing.T) { @@ -16,17 +18,7 @@ func TestRequirePasswordChange(t *testing.T) { user := insertUser(t, gdb, "alice@example.com", "correct-horse") p := &Plugin{app: app} var _ pact.HasCommands = p - cmds := p.Commands() - var cmd bonfire.Command - for _, candidate := range cmds { - if candidate.Name == "user:require-password-change" { - cmd = candidate - break - } - } - if cmd.Name == "" { - t.Fatalf("commands = %#v", cmds) - } + cmd := commandNamed(t, p.Commands(), "user:require-password-change") var buf bytes.Buffer out := bonfire.NewOutput(nil, &buf, &buf) if err := cmd.Run(context.Background(), emailArg{email: "alice@example.com"}, out); err != nil { @@ -47,6 +39,55 @@ func TestRequirePasswordChange(t *testing.T) { } } +func TestAPITokenCreateCommand(t *testing.T) { + app, gdb := sessionApp(t, "0s") + user := insertUser(t, gdb, "pat@example.com", "correct-horse") + cmd := commandNamed(t, (&Plugin{app: app}).Commands(), "user:token:create") + var buf bytes.Buffer + out := bonfire.NewOutput(nil, &buf, &buf) + if err := cmd.Run(context.Background(), tokenCreateArg{email: user.Email, name: "rome scripts", scopes: "read,write"}, out); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "usr_") { + t.Fatalf("secret missing from output %q", buf.String()) + } + var n int64 + if err := gdb.Model(&models.APIToken{}).Where("user_id = ? AND revoked_at IS NULL", user.ID).Count(&n).Error; err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("tokens = %d", n) + } +} + +func TestConsoleWithDBOpensFromConfigWhenUnpublished(t *testing.T) { + p := &Plugin{app: backpack.New(sessionConfig(t))} + err := p.withDB(context.Background(), func(*gorm.DB) error { + t.Fatal("fn should not run without a DSN") + return nil + }) + if err == nil { + t.Fatal("expected OpenFromApp to fail") + } + if strings.Contains(err.Error(), "database is not configured") { + t.Fatalf("still the Lookup-only error: %v", err) + } + if !strings.Contains(err.Error(), "database.dsn is empty") { + t.Fatalf("got %v", err) + } +} + +func commandNamed(t *testing.T, cmds []bonfire.Command, name string) bonfire.Command { + t.Helper() + for _, candidate := range cmds { + if candidate.Name == name { + return candidate + } + } + t.Fatalf("commands = %#v", cmds) + return bonfire.Command{} +} + type emailArg struct{ email string } func (e emailArg) Args() []string { return []string{e.email} } @@ -61,3 +102,27 @@ func (e emailArg) Argument(name string) (string, bool) { func (e emailArg) Flag(string) (string, bool) { return "", false } func (e emailArg) Flags(string) []string { return nil } + +type tokenCreateArg struct{ email, name, scopes string } + +func (a tokenCreateArg) Args() []string { return []string{a.email} } + +func (a tokenCreateArg) Argument(name string) (string, bool) { + if name == "email" { + return a.email, true + } + return "", false +} + +func (a tokenCreateArg) Flag(name string) (string, bool) { + switch name { + case "name": + return a.name, true + case "scopes": + return a.scopes, true + default: + return "", false + } +} + +func (a tokenCreateArg) Flags(string) []string { return nil } diff --git a/plugin.go b/plugin.go index ea1f46c..3973ad7 100644 --- a/plugin.go +++ b/plugin.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "embed" + "fmt" "io/fs" "log/slog" "net/http" @@ -18,6 +19,7 @@ import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/surf" @@ -201,7 +203,25 @@ func (p *Plugin) MailLayouts() map[string]string { } func (p *Plugin) Commands() []bonfire.Command { - return append([]bonfire.Command{console.RequirePasswordChange(p.app)}, console.APITokenCommands(p.app)...) + return append([]bonfire.Command{console.RequirePasswordChange(p.withDB)}, console.APITokenCommands(p.app, p.withDB)...) +} + +// withDB runs fn with the application's database: the one the serve command +// published, or, when a console command runs, one opened from the config for +// the duration of fn. +func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error { + if p == nil || p.app == nil { + return fmt.Errorf("golem15.user: app is nil") + } + if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil { + return fn(gdb) + } + sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app) + if err != nil { + return err + } + defer sqlDB.Close() + return fn(gdb) } func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }