182 lines
6.2 KiB
Go
182 lines
6.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
|
)
|
|
|
|
func roleID(t *testing.T, pg *repository.Postgres, key string) string {
|
|
t.Helper()
|
|
var id string
|
|
if err := pg.Pool.QueryRow(context.Background(),
|
|
`SELECT id FROM admin_roles WHERE key = $1`, key).Scan(&id); err != nil {
|
|
t.Fatalf("role %s: %v", key, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func makeStaff(t *testing.T, pg *repository.Postgres, roleKey string) string {
|
|
t.Helper()
|
|
id := chatUser(t, pg, roleKey)
|
|
if _, err := pg.Pool.Exec(context.Background(),
|
|
`UPDATE users SET admin_role_id = $2 WHERE id = $1`, id, roleID(t, pg, roleKey)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// The floor: removing the last active superadmin would leave nobody able to
|
|
// manage staff, with no way back from the UI.
|
|
func TestStaff_LastSuperadminIsProtected(t *testing.T) {
|
|
_, _, pg := testChatService(t)
|
|
ctx := context.Background()
|
|
staff := repository.NewStaffRepo(pg)
|
|
svc := NewStaffService(staff, nil, nil, "")
|
|
|
|
// Isolate from any staff already in the dev database: the floor counts
|
|
// superadmins globally. Parked in a REAL table — a TEMP table is
|
|
// per-connection, and the pool may run the cleanup on a different one,
|
|
// which would leave real staff stripped of their roles.
|
|
parked := "rbac_parked_" + strings.ReplaceAll(uuid.NewString()[:8], "-", "")
|
|
if _, err := pg.Pool.Exec(ctx, `CREATE TABLE `+parked+` AS
|
|
SELECT id, admin_role_id FROM users WHERE admin_role_id IS NOT NULL`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := pg.Pool.Exec(ctx, `UPDATE users SET admin_role_id = NULL
|
|
WHERE id IN (SELECT id FROM `+parked+`)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if _, err := pg.Pool.Exec(ctx, `UPDATE users u SET admin_role_id = p.admin_role_id
|
|
FROM `+parked+` p WHERE u.id = p.id`); err != nil {
|
|
t.Errorf("restoring parked staff: %v", err)
|
|
return
|
|
}
|
|
_, _ = pg.Pool.Exec(ctx, `DROP TABLE `+parked)
|
|
})
|
|
|
|
a := makeStaff(t, pg, "superadmin")
|
|
b := makeStaff(t, pg, "superadmin")
|
|
mod := makeStaff(t, pg, "moderator")
|
|
|
|
// Nobody acts on themselves.
|
|
if err := svc.Revoke(ctx, a, a); !errors.Is(err, ErrStaffSelf) {
|
|
t.Errorf("self-revoke: %v, want ErrStaffSelf", err)
|
|
}
|
|
if _, err := svc.SetBlocked(ctx, a, a, true); !errors.Is(err, ErrStaffSelf) {
|
|
t.Errorf("self-block: %v, want ErrStaffSelf", err)
|
|
}
|
|
|
|
// A superadmin can block another admin/mod — and another superadmin while
|
|
// one remains.
|
|
if _, err := svc.SetBlocked(ctx, a, mod, true); err != nil {
|
|
t.Errorf("block moderator: %v", err)
|
|
}
|
|
if _, err := svc.SetBlocked(ctx, a, b, true); err != nil {
|
|
t.Errorf("block second superadmin: %v", err)
|
|
}
|
|
// Now a is the only active one: a blocked b can't be used to demote a.
|
|
if _, err := svc.ChangeRole(ctx, b, a, roleID(t, pg, "moderator")); !errors.Is(err, ErrLastSuperadmin) {
|
|
t.Errorf("demoting the last active superadmin: %v, want ErrLastSuperadmin", err)
|
|
}
|
|
if err := svc.Revoke(ctx, b, a); !errors.Is(err, ErrLastSuperadmin) {
|
|
t.Errorf("revoking the last active superadmin: %v, want ErrLastSuperadmin", err)
|
|
}
|
|
|
|
// A blocked staff member is turned away at the door.
|
|
acc, err := staff.Access(ctx, mod)
|
|
if err != nil || !acc.Blocked {
|
|
t.Errorf("blocked moderator access = %+v, %v", acc, err)
|
|
}
|
|
}
|
|
|
|
// A custom role is built only from the catalog — never the wildcard or
|
|
// admins:manage, which would make it a second superadmin.
|
|
func TestStaff_CustomRolesCannotEscalate(t *testing.T) {
|
|
_, _, pg := testChatService(t)
|
|
ctx := context.Background()
|
|
svc := NewStaffService(repository.NewStaffRepo(pg), nil, nil, "")
|
|
|
|
for _, perms := range [][]string{{rbac.Wildcard}, {rbac.ManageStaff}, {"users:read", "made:up"}} {
|
|
if _, err := svc.CreateRole(ctx, "Escalator", "", perms); !errors.Is(err, ErrRoleBadPermission) {
|
|
t.Errorf("CreateRole(%v) = %v, want ErrRoleBadPermission", perms, err)
|
|
}
|
|
}
|
|
|
|
ro, err := svc.CreateRole(ctx, "Quiz Editors", "", []string{"quizzes:read", "quizzes:write"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = svc.DeleteRole(ctx, ro.ID) })
|
|
|
|
set := rbac.NewSet(ro.Permissions)
|
|
if !set.Has("quizzes:write") || set.Has("users:ban") || set.Has(rbac.ManageStaff) {
|
|
t.Errorf("custom role permissions resolved wrong: %v", set.List())
|
|
}
|
|
|
|
// System roles are read-only.
|
|
if _, err := svc.UpdateRole(ctx, roleID(t, pg, "moderator"), "Mod", "", []string{"users:ban"}); !errors.Is(err, ErrRoleSystem) {
|
|
t.Errorf("editing a system role: %v, want ErrRoleSystem", err)
|
|
}
|
|
}
|
|
|
|
// captureMailer records sends; the welcome email goes out on a goroutine.
|
|
type captureMailer struct{ sent chan [3]string }
|
|
|
|
func (m *captureMailer) Send(to, subject, body string) error {
|
|
m.sent <- [3]string{to, subject, body}
|
|
return nil
|
|
}
|
|
|
|
// Adding someone as staff emails them their role and where to log in; moving
|
|
// an existing staff member to another role does not.
|
|
func TestStaff_WelcomeEmailOnGrant(t *testing.T) {
|
|
_, _, pg := testChatService(t)
|
|
ctx := context.Background()
|
|
mail := &captureMailer{sent: make(chan [3]string, 4)}
|
|
svc := NewStaffService(repository.NewStaffRepo(pg), mail, nil, "")
|
|
|
|
actor := chatUser(t, pg, "Actor")
|
|
target, handle := chatUserWithHandle(t, pg, "Target")
|
|
t.Cleanup(func() {
|
|
_, _ = pg.Pool.Exec(context.Background(), `UPDATE users SET admin_role_id = NULL WHERE id = $1`, target)
|
|
})
|
|
|
|
if _, err := svc.Grant(ctx, actor, "@"+handle, roleID(t, pg, "moderator")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case got := <-mail.sent:
|
|
if !strings.HasPrefix(got[0], "chatsvc-") {
|
|
t.Errorf("sent to %q, want the target's address", got[0])
|
|
}
|
|
for _, want := range []string{"promoted to <strong>Moderator</strong>", "https://admin.prabhupadasays.org", "contact your co-ordinator"} {
|
|
if !strings.Contains(got[2], want) {
|
|
t.Errorf("email body missing %q", want)
|
|
}
|
|
}
|
|
if !strings.Contains(got[1], "Moderator") {
|
|
t.Errorf("subject %q doesn't name the role", got[1])
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("no welcome email was sent")
|
|
}
|
|
|
|
if _, err := svc.ChangeRole(ctx, actor, target, roleID(t, pg, "admin")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case got := <-mail.sent:
|
|
t.Errorf("a role change sent an email: %q", got[1])
|
|
case <-time.After(300 * time.Millisecond):
|
|
}
|
|
}
|