Files
spsays-bff/internal/service/staff_service_test.go
T
2026-10-15 19:43:17 +05:30

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):
}
}