229 lines
7.9 KiB
Go
229 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|