From d27f442c4991bcd7a2c11f13f84ca5724f709c0f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 24 Sep 2026 17:54:42 +0200 Subject: [PATCH] test(09-02): add failing tests for admin create and reset commands - admin:create and admin:reset-password are not registered yet - Generated app main does not append cabana runtime commands --- cabana/commands.go | 11 ++ cabana/commands_test.go | 197 +++++++++++++++++++++++++++++++++++ internal/build/build_test.go | 16 +++ 3 files changed, 224 insertions(+) create mode 100644 cabana/commands.go create mode 100644 cabana/commands_test.go diff --git a/cabana/commands.go b/cabana/commands.go new file mode 100644 index 0000000..fe9eb02 --- /dev/null +++ b/cabana/commands.go @@ -0,0 +1,11 @@ +package cabana + +import ( + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bonfire" +) + +// RuntimeCommands returns the operator commands for backend administrators. +func RuntimeCommands(app *backpack.App) []bonfire.Command { + return nil +} diff --git a/cabana/commands_test.go b/cabana/commands_test.go new file mode 100644 index 0000000..e7b5cb7 --- /dev/null +++ b/cabana/commands_test.go @@ -0,0 +1,197 @@ +package cabana_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "os" + "path/filepath" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/bouncer" + "git.golem15.com/golem15/summercms/cabana" + "git.golem15.com/golem15/summercms/compass" + "git.golem15.com/golem15/summercms/lagoon" + "gorm.io/gorm" +) + +func TestAdminCreateCommand(t *testing.T) { + gdb := adminGorm(t) + app := commandApp(t, gdb) + create := commandByName(t, cabana.RuntimeCommands(app), "admin:create") + var buf bytes.Buffer + out := bonfire.NewOutput(nil, &buf, &buf) + const password = "correct-horse-battery" + if err := create.Run(context.Background(), flagInput{flags: map[string]string{ + "email": "Ada@Example.Test", + "password": password, + "role": "developer", + "superuser": "true", + }}, out); err != nil { + t.Fatal(err) + } + if strings.Contains(buf.String(), password) || strings.Contains(buf.String(), "$2") || strings.Contains(buf.String(), adminTestSecret) { + t.Fatalf("create output leaked a secret: %s", buf.String()) + } + if !strings.Contains(buf.String(), "ada@example.test") { + t.Fatalf("create output = %s", buf.String()) + } + var user cabana.BackendUser + if err := gdb.Preload("Role").Where("login = ?", "ada@example.test").First(&user).Error; err != nil { + t.Fatal(err) + } + if user.Email != "ada@example.test" || !user.IsActivated || !user.IsSuperuser || user.Role.Code != "developer" || user.ActivatedAt == nil { + t.Fatalf("created admin = %+v role=%+v", user, user.Role) + } + if !bouncer.CheckPassword(user.Password, password) { + t.Fatal("stored password hash does not match") + } + buf.Reset() + if err := create.Run(context.Background(), flagInput{flags: map[string]string{ + "email": "editor@example.test", + "password": password, + "login": "editor", + "role": "publisher", + }}, out); err != nil { + t.Fatal(err) + } + var editor cabana.BackendUser + if err := gdb.Where("login = ?", "editor").First(&editor).Error; err != nil { + t.Fatal(err) + } + if editor.IsSuperuser || editor.Email != "editor@example.test" { + t.Fatalf("editor = %+v", editor) + } + if err := create.Run(context.Background(), flagInput{flags: map[string]string{ + "email": "third@example.test", "password": password, "role": "missing-role", + }}, out); err == nil || !strings.Contains(err.Error(), "unknown role") { + t.Fatalf("unknown role err = %v", err) + } + if err := gdb.Exec(`INSERT INTO backend_user_roles (name, code, is_system, created_at, updated_at) VALUES ('Editor A', 'shared', FALSE, NOW(), NOW()), ('Editor B', 'shared', FALSE, NOW(), NOW())`).Error; err != nil { + t.Fatal(err) + } + err := create.Run(context.Background(), flagInput{flags: map[string]string{ + "email": "fourth@example.test", "password": password, "role": "shared", + }}, out) + if err == nil || !strings.Contains(err.Error(), "ambiguous role") || strings.Contains(err.Error(), password) { + t.Fatalf("ambiguous role err = %v", err) + } +} + +func TestAdminResetPasswordCommand(t *testing.T) { + gdb := adminGorm(t) + app := commandApp(t, gdb) + reset := commandByName(t, cabana.RuntimeCommands(app), "admin:reset-password") + const original = "original-password-value" + const updated = "replacement-password-value" + hash, err := bouncer.HashPassword(10, original) + if err != nil { + t.Fatal(err) + } + var roleID uint + if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&roleID).Error; err != nil { + t.Fatal(err) + } + user := cabana.BackendUser{Login: "resetme", Email: "resetme@example.test", Password: hash, IsActivated: true, RoleID: &roleID} + if err := gdb.Create(&user).Error; err != nil { + t.Fatal(err) + } + token, _, err := bouncer.MintAudience(adminTestSecret, itoa(user.ID), "https://app.test/_admin/api/v1/auth/login", time.Hour, bouncer.AudienceBackend) + if err != nil { + t.Fatal(err) + } + guard := bouncer.NewBackendJWTGuard(adminTestSecret, cabana.BackendUsers{DB: gdb}, nil, nil) + req := httptest.NewRequest(http.MethodGet, "/_admin/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token) + if _, err := guard.Authenticate(req); err != nil { + t.Fatalf("token before reset: %v", err) + } + var buf bytes.Buffer + out := bonfire.NewOutput(nil, &buf, &buf) + if err := reset.Run(context.Background(), flagInput{ + args: []string{"ResetMe@Example.Test"}, + flags: map[string]string{"password": updated}, + }, out); err != nil { + t.Fatal(err) + } + if strings.Contains(buf.String(), updated) || strings.Contains(buf.String(), original) || strings.Contains(buf.String(), "$2") || strings.Contains(buf.String(), token) { + t.Fatalf("reset output leaked a secret: %s", buf.String()) + } + if !strings.Contains(buf.String(), "resetme") { + t.Fatalf("reset output = %s", buf.String()) + } + var fresh cabana.BackendUser + if err := gdb.First(&fresh, user.ID).Error; err != nil { + t.Fatal(err) + } + if bouncer.CheckPassword(fresh.Password, original) || !bouncer.CheckPassword(fresh.Password, updated) { + t.Fatal("password hash was not replaced") + } + if fresh.TokensValidAfter == nil { + t.Fatal("reset did not advance tokens_valid_after") + } + if _, err := guard.Authenticate(req); err == nil { + t.Fatal("token issued before reset still authenticated") + } +} + +func commandByName(t *testing.T, cmds []bonfire.Command, name string) bonfire.Command { + t.Helper() + for _, cmd := range cmds { + if cmd.Name == name && cmd.Run != nil { + return cmd + } + } + t.Fatalf("%s was not registered", name) + return bonfire.Command{} +} + +func commandApp(t *testing.T, gdb *gorm.DB) *backpack.App { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-admin\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{ + Dir: dir, + Environ: []string{ + "SUMMER_ENV=development", + "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret, + }, + }) + if err != nil { + t.Fatal(err) + } + app := backpack.New(cfg) + if err := lagoon.Publish(app, adminSQL, gdb); err != nil { + t.Fatal(err) + } + return app +} + +type flagInput struct { + args []string + flags map[string]string +} + +func (f flagInput) Args() []string { return f.args } + +func (f flagInput) Argument(name string) (string, bool) { + if name == "identifier" && len(f.args) > 0 { + return f.args[0], true + } + return "", false +} + +func (f flagInput) Flag(name string) (string, bool) { + value, ok := f.flags[name] + return value, ok && value != "" +} + +func (f flagInput) Flags(string) []string { return nil } diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 136d1f5..6bd4868 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -109,6 +109,22 @@ func TestGenerateStableQuotedImportsInManifestOrder(t *testing.T) { } } +func TestGenerateMainRegistersCabanaRuntimeCommands(t *testing.T) { + mainSrc, err := generateMain(Manifest{Module: "example.com/app", Binary: "hello"}) + if err != nil { + t.Fatal(err) + } + if bytes.Count(mainSrc, []byte("cabana.RuntimeCommands")) != 1 { + t.Fatalf("cabana.RuntimeCommands count = %d\n%s", bytes.Count(mainSrc, []byte("cabana.RuntimeCommands")), mainSrc) + } + if !bytes.Contains(mainSrc, []byte("commands = append(commands, cabana.RuntimeCommands(app)...)")) { + t.Fatalf("generated main does not append cabana runtime commands once:\n%s", mainSrc) + } + if bytes.Contains(mainSrc, []byte("examples/hello")) { + t.Fatal("generated main hard-codes examples/hello") + } +} + func TestWriteIfChangedSkipsIdenticalBytes(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "out.go")