package cabana_test import ( "bytes" "context" "net/http" "net/http/httptest" "strings" "testing" "time" "os" "path/filepath" "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/cabana" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/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, adminAPI("/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 } // TestAdminCreateRejectsCrossFieldCollision pins WR-11: admin:create refuses a // login that equals another admin's email and an email that equals another // admin's login, because either makes the login identifier ambiguous. func TestAdminCreateRejectsCrossFieldCollision(t *testing.T) { gdb := adminGorm(t) app := commandApp(t, gdb) create := commandByName(t, cabana.RuntimeCommands(app), "admin:create") out := bonfire.NewOutput(nil, &bytes.Buffer{}, &bytes.Buffer{}) const password = "correct-horse-battery" if err := create.Run(context.Background(), flagInput{flags: map[string]string{ "email": "a-xf@example.test", "login": "login-xf@example.test", "password": password, }}, out); err != nil { t.Fatal(err) } for name, flags := range map[string]map[string]string{ "email equals an existing login": {"email": "login-xf@example.test", "login": "b-xf"}, "login equals an existing email": {"email": "c-xf@example.test", "login": "a-xf@example.test"}, "email differs only by case": {"email": "A-XF@example.test", "login": "d-xf"}, } { flags["password"] = password err := create.Run(context.Background(), flagInput{flags: flags}, out) if err == nil || !strings.Contains(err.Error(), "already exists") { t.Fatalf("%s: err = %v, want an already-exists refusal", name, err) } } var n int64 if err := gdb.Model(&cabana.BackendUser{}).Where("login LIKE ?", "%-xf%").Count(&n).Error; err != nil || n != 1 { t.Fatalf("admins after refused creates = %d, %v; want 1", n, err) } } // TestAdminPasswordWithoutFlag pins WR-13: the password can come from stdin // (the non-terminal path of the prompt) instead of the command line, the // --password flag still works but warns that it is deprecated, and no password // at all is refused. func TestAdminPasswordWithoutFlag(t *testing.T) { gdb := adminGorm(t) app := commandApp(t, gdb) create := commandByName(t, cabana.RuntimeCommands(app), "admin:create") reset := commandByName(t, cabana.RuntimeCommands(app), "admin:reset-password") const piped = "password-piped-on-stdin" var buf bytes.Buffer out := bonfire.NewOutput(strings.NewReader(piped+"\n"), &buf, &buf) if err := create.Run(context.Background(), flagInput{flags: map[string]string{"email": "stdin-pw@example.test"}}, out); err != nil { t.Fatalf("create with the password on stdin: %v", err) } var user cabana.BackendUser if err := gdb.Where("login = ?", "stdin-pw@example.test").First(&user).Error; err != nil { t.Fatal(err) } if !bouncer.CheckPassword(user.Password, piped) { t.Fatal("password read from stdin was not stored") } if strings.Contains(buf.String(), piped) || strings.Contains(buf.String(), "deprecated") { t.Fatalf("stdin path leaked the password or warned: %s", buf.String()) } const next = "replacement-piped-on-stdin" buf.Reset() out = bonfire.NewOutput(strings.NewReader(next+"\n"), &buf, &buf) if err := reset.Run(context.Background(), flagInput{args: []string{"stdin-pw@example.test"}}, out); err != nil { t.Fatalf("reset with the password on stdin: %v", err) } if err := gdb.Where("login = ?", "stdin-pw@example.test").First(&user).Error; err != nil || !bouncer.CheckPassword(user.Password, next) { t.Fatalf("reset did not store the stdin password: %v", err) } buf.Reset() out = bonfire.NewOutput(strings.NewReader(""), &buf, &buf) err := reset.Run(context.Background(), flagInput{args: []string{"stdin-pw@example.test"}}, out) if err == nil || !strings.Contains(err.Error(), "password is required") { t.Fatalf("reset with no password err = %v", err) } buf.Reset() out = bonfire.NewOutput(strings.NewReader(""), &buf, &buf) if err := reset.Run(context.Background(), flagInput{args: []string{"stdin-pw@example.test"}, flags: map[string]string{"password": "flag-password-value"}}, out); err != nil { t.Fatalf("reset with the deprecated flag: %v", err) } if !strings.Contains(buf.String(), "deprecated") || strings.Contains(buf.String(), "flag-password-value") { t.Fatalf("deprecated flag output = %s", buf.String()) } }