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 Moderator", "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): } }