feat: quiz and rbac
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
maxQuizQuestions = 100
|
||||
minQuizOptions = 2
|
||||
maxQuizOptions = 5
|
||||
maxQuizTitle = 200
|
||||
)
|
||||
|
||||
var (
|
||||
ErrQuizTitle = errors.New("a quiz needs a title")
|
||||
ErrQuizNoQuestions = errors.New("a quiz needs at least one question")
|
||||
ErrQuizTooMany = fmt.Errorf("a quiz can have at most %d questions", maxQuizQuestions)
|
||||
ErrQuizPrompt = errors.New("every question needs a prompt")
|
||||
ErrQuizOptionCount = fmt.Errorf("each question needs %d to %d options", minQuizOptions, maxQuizOptions)
|
||||
ErrQuizOptionBody = errors.New("every option needs text")
|
||||
ErrQuizOneCorrect = errors.New("a single-answer question needs exactly one correct option")
|
||||
ErrQuizNoCorrect = errors.New("a multiple-answer question needs at least one correct option")
|
||||
ErrQuizLocked = errors.New("people have already taken this quiz, so its questions can't change")
|
||||
ErrQuizTarget = errors.New("that link isn't a book chapter, lecture or conversation")
|
||||
ErrQuizUnpublished = errors.New("this quiz isn't available")
|
||||
)
|
||||
|
||||
// QuizService is both sides of quizzes: authoring for staff, taking for users.
|
||||
type QuizService struct {
|
||||
quizzes *repository.QuizRepo
|
||||
// content resolves a pasted link into a target title and kind.
|
||||
content *repository.VaniRepo
|
||||
}
|
||||
|
||||
func NewQuizService(quizzes *repository.QuizRepo, content *repository.VaniRepo) *QuizService {
|
||||
return &QuizService{quizzes: quizzes, content: content}
|
||||
}
|
||||
|
||||
// QuizDraft is what the admin editor submits. TargetURL is pasted from the
|
||||
// app; empty means a stand-alone quiz.
|
||||
type QuizDraft struct {
|
||||
Title string
|
||||
Description string
|
||||
TargetURL string
|
||||
// TargetTitle overrides the resolved content title when set.
|
||||
TargetTitle string
|
||||
Questions []repository.QuestionInput
|
||||
}
|
||||
|
||||
// NormalizeContentPath reduces a pasted link to the path a content page
|
||||
// reports for itself: no scheme, host, query, fragment or trailing slash.
|
||||
// Both sides normalise identically, so "https://…/books/bg/2/?x" and the
|
||||
// page's own "/books/bg/2" meet.
|
||||
func NormalizeContentPath(raw string) string {
|
||||
p := strings.TrimSpace(raw)
|
||||
if u, err := url.Parse(p); err == nil && u.Host != "" {
|
||||
p = u.Path
|
||||
}
|
||||
p = strings.SplitN(strings.SplitN(p, "?", 2)[0], "#", 2)[0]
|
||||
p = "/" + strings.Trim(p, "/")
|
||||
if p == "/" {
|
||||
return ""
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// resolveTarget works out what a link points at.
|
||||
func (s *QuizService) resolveTarget(ctx context.Context, raw, override string) (kind string, path *string, title string, err error) {
|
||||
p := NormalizeContentPath(raw)
|
||||
if p == "" {
|
||||
return model.QuizStandalone, nil, "", nil
|
||||
}
|
||||
parts := strings.Split(strings.Trim(p, "/"), "/")
|
||||
|
||||
switch {
|
||||
case len(parts) == 2 && parts[0] == "transcriptions":
|
||||
t, err := s.content.TranscriptBySlug(ctx, parts[1])
|
||||
if err != nil {
|
||||
return "", nil, "", ErrQuizTarget
|
||||
}
|
||||
kind = model.QuizLecture
|
||||
if t.Type == "conversation" {
|
||||
kind = model.QuizConversation
|
||||
}
|
||||
title = t.Name
|
||||
|
||||
case len(parts) >= 3 && parts[0] == "books":
|
||||
kind = model.QuizChapter
|
||||
book := parts[1]
|
||||
bookTitle := s.content.BookTitle(ctx, book)
|
||||
if bookTitle == "" {
|
||||
bookTitle = strings.ToUpper(book)
|
||||
}
|
||||
last := parts[len(parts)-1]
|
||||
if _, numErr := strconv.Atoi(last); numErr == nil {
|
||||
// /books/bg/2, /books/sb/1/2, /books/cc/adi/3 — a numbered chapter.
|
||||
title = bookTitle + " — Chapter " + last
|
||||
} else if pr, err := s.content.ProseBySlug(ctx, book, last); err == nil {
|
||||
title = pr.Title
|
||||
} else {
|
||||
title = bookTitle
|
||||
}
|
||||
|
||||
default:
|
||||
return "", nil, "", ErrQuizTarget
|
||||
}
|
||||
|
||||
if o := strings.TrimSpace(override); o != "" {
|
||||
title = o
|
||||
}
|
||||
return kind, &p, title, nil
|
||||
}
|
||||
|
||||
func validateQuestions(qs []repository.QuestionInput) ([]repository.QuestionInput, error) {
|
||||
if len(qs) == 0 {
|
||||
return nil, ErrQuizNoQuestions
|
||||
}
|
||||
if len(qs) > maxQuizQuestions {
|
||||
return nil, ErrQuizTooMany
|
||||
}
|
||||
out := make([]repository.QuestionInput, 0, len(qs))
|
||||
for _, q := range qs {
|
||||
q.Prompt = strings.TrimSpace(q.Prompt)
|
||||
q.Explanation = strings.TrimSpace(q.Explanation)
|
||||
if q.Prompt == "" {
|
||||
return nil, ErrQuizPrompt
|
||||
}
|
||||
if len(q.Options) < minQuizOptions || len(q.Options) > maxQuizOptions {
|
||||
return nil, ErrQuizOptionCount
|
||||
}
|
||||
correct := 0
|
||||
for i := range q.Options {
|
||||
q.Options[i].Body = strings.TrimSpace(q.Options[i].Body)
|
||||
if q.Options[i].Body == "" {
|
||||
return nil, ErrQuizOptionBody
|
||||
}
|
||||
if q.Options[i].IsCorrect {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
if q.Multi && correct == 0 {
|
||||
return nil, ErrQuizNoCorrect
|
||||
}
|
||||
if !q.Multi && correct != 1 {
|
||||
return nil, ErrQuizOneCorrect
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *QuizService) toInput(ctx context.Context, d QuizDraft, withQuestions bool) (repository.QuizInput, error) {
|
||||
in := repository.QuizInput{
|
||||
Title: strings.TrimSpace(d.Title),
|
||||
Description: strings.TrimSpace(d.Description),
|
||||
}
|
||||
if in.Title == "" || len([]rune(in.Title)) > maxQuizTitle {
|
||||
return in, ErrQuizTitle
|
||||
}
|
||||
var err error
|
||||
if in.TargetKind, in.TargetPath, in.TargetTitle, err = s.resolveTarget(ctx, d.TargetURL, d.TargetTitle); err != nil {
|
||||
return in, err
|
||||
}
|
||||
if withQuestions {
|
||||
if in.Questions, err = validateQuestions(d.Questions); err != nil {
|
||||
return in, err
|
||||
}
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// ── Admin ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *QuizService) ListAdmin(ctx context.Context) ([]model.Quiz, error) {
|
||||
return s.quizzes.ListAdmin(ctx)
|
||||
}
|
||||
|
||||
func (s *QuizService) GetAdmin(ctx context.Context, id string) (*model.Quiz, error) {
|
||||
q, err := s.quizzes.Get(ctx, id, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if q.AttemptCount, err = s.quizzes.AttemptCount(ctx, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (s *QuizService) Create(ctx context.Context, actorID string, d QuizDraft) (*model.Quiz, error) {
|
||||
in, err := s.toInput(ctx, d, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := s.quizzes.Create(ctx, in, actorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetAdmin(ctx, id)
|
||||
}
|
||||
|
||||
// Update rewrites a quiz. Once anyone has attempted it, its questions are
|
||||
// frozen — their answers reference those exact questions and options — and
|
||||
// only the title, description and target may change. Sending questions then
|
||||
// is refused rather than silently ignored.
|
||||
func (s *QuizService) Update(ctx context.Context, id string, d QuizDraft) (*model.Quiz, error) {
|
||||
attempts, err := s.quizzes.AttemptCount(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locked := attempts > 0
|
||||
if locked && len(d.Questions) > 0 {
|
||||
return nil, ErrQuizLocked
|
||||
}
|
||||
in, err := s.toInput(ctx, d, !locked)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.quizzes.Update(ctx, id, in, !locked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetAdmin(ctx, id)
|
||||
}
|
||||
|
||||
func (s *QuizService) SetPublished(ctx context.Context, id string, published bool) (*model.Quiz, error) {
|
||||
if published {
|
||||
q, err := s.quizzes.Get(ctx, id, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(q.Questions) == 0 {
|
||||
return nil, ErrQuizNoQuestions
|
||||
}
|
||||
}
|
||||
if err := s.quizzes.SetPublished(ctx, id, published); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetAdmin(ctx, id)
|
||||
}
|
||||
|
||||
func (s *QuizService) Delete(ctx context.Context, id string) error {
|
||||
return s.quizzes.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *QuizService) Participants(ctx context.Context, id string) ([]model.QuizParticipant, error) {
|
||||
if _, err := s.quizzes.Get(ctx, id, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.quizzes.Participants(ctx, id)
|
||||
}
|
||||
|
||||
func (s *QuizService) Responses(ctx context.Context, id string) ([]model.QuizQuestionStat, error) {
|
||||
if _, err := s.quizzes.Get(ctx, id, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.quizzes.QuestionStats(ctx, id)
|
||||
}
|
||||
|
||||
// ParticipantResult is one person's answers, for the admin — including an
|
||||
// attempt still in progress, which a user can never see of their own.
|
||||
func (s *QuizService) ParticipantResult(ctx context.Context, quizID, userID string) (*model.QuizResult, error) {
|
||||
return s.quizzes.Result(ctx, quizID, userID, true)
|
||||
}
|
||||
|
||||
// ── Taking ────────────────────────────────────────────────────────────────
|
||||
|
||||
// published loads a quiz that users may see.
|
||||
func (s *QuizService) published(ctx context.Context, id string) (*model.Quiz, error) {
|
||||
q, err := s.quizzes.Get(ctx, id, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if q.PublishedAt == nil {
|
||||
return nil, ErrQuizUnpublished
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (s *QuizService) withStatus(ctx context.Context, userID string, qs []model.Quiz) ([]model.QuizCard, error) {
|
||||
ids := make([]string, len(qs))
|
||||
for i, q := range qs {
|
||||
ids[i] = q.ID
|
||||
}
|
||||
st, err := s.quizzes.Statuses(ctx, userID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]model.QuizCard, len(qs))
|
||||
for i, q := range qs {
|
||||
q.Questions = nil
|
||||
my, ok := st[q.ID]
|
||||
if !ok {
|
||||
my = model.QuizStatus{State: model.QuizNotStarted, Total: q.QuestionCount}
|
||||
}
|
||||
out[i] = model.QuizCard{Quiz: q, My: my}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ForPath returns the quiz on a content page with the caller's progress, or
|
||||
// nil when the page has none. userID may be empty for signed-out readers.
|
||||
func (s *QuizService) ForPath(ctx context.Context, userID, rawPath string) (*model.QuizCard, error) {
|
||||
p := NormalizeContentPath(rawPath)
|
||||
if p == "" {
|
||||
return nil, nil
|
||||
}
|
||||
q, err := s.quizzes.PublishedForPath(ctx, p)
|
||||
if err != nil || q == nil {
|
||||
return nil, err
|
||||
}
|
||||
cards, err := s.withStatus(ctx, userID, []model.Quiz{*q})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cards[0], nil
|
||||
}
|
||||
|
||||
func (s *QuizService) ListPublished(ctx context.Context, userID string) ([]model.QuizCard, error) {
|
||||
qs, err := s.quizzes.ListPublished(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.withStatus(ctx, userID, qs)
|
||||
}
|
||||
|
||||
func (s *QuizService) Card(ctx context.Context, userID, id string) (*model.QuizCard, error) {
|
||||
q, err := s.published(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards, err := s.withStatus(ctx, userID, []model.Quiz{*q})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cards[0], nil
|
||||
}
|
||||
|
||||
// Start opens (or resumes) the caller's attempt and returns where they are.
|
||||
func (s *QuizService) Start(ctx context.Context, userID, id string) (*model.QuizPlay, error) {
|
||||
q, err := s.published(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.quizzes.Start(ctx, id, userID, len(q.Questions)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.next(ctx, userID, q)
|
||||
}
|
||||
|
||||
func (s *QuizService) next(ctx context.Context, userID string, q *model.Quiz) (*model.QuizPlay, error) {
|
||||
play, err := s.quizzes.Next(ctx, q.ID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
play.Title = q.Title
|
||||
return play, nil
|
||||
}
|
||||
|
||||
// Answer records a choice — one option, or several on a multi question — and
|
||||
// returns the next step.
|
||||
func (s *QuizService) Answer(ctx context.Context, userID, id, questionID string, optionIDs []string) (*model.QuizPlay, error) {
|
||||
q, err := s.published(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.quizzes.Answer(ctx, id, userID, questionID, optionIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.next(ctx, userID, q)
|
||||
}
|
||||
|
||||
// Result is the caller's OWN finished attempt — the only result a user can
|
||||
// read. The user id comes from the session, never the request.
|
||||
func (s *QuizService) Result(ctx context.Context, userID, id string) (*model.QuizResult, error) {
|
||||
if _, err := s.quizzes.Get(ctx, id, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.quizzes.Result(ctx, id, userID, false)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
func q(prompt string, correct int, opts ...string) repository.QuestionInput {
|
||||
out := repository.QuestionInput{Prompt: prompt}
|
||||
for i, o := range opts {
|
||||
out.Options = append(out.Options, repository.OptionInput{Body: o, IsCorrect: i == correct})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func withExplanation(x repository.QuestionInput, e string) repository.QuestionInput {
|
||||
x.Explanation = e
|
||||
return x
|
||||
}
|
||||
|
||||
func newQuizSvc(t *testing.T) (*QuizService, *repository.Postgres) {
|
||||
t.Helper()
|
||||
_, _, pg := testChatService(t)
|
||||
// Stand-alone quizzes never consult content, so no Mongo is needed.
|
||||
return NewQuizService(repository.NewQuizRepo(pg), nil), pg
|
||||
}
|
||||
|
||||
func cleanupQuiz(t *testing.T, pg *repository.Postgres, id string) {
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM quizzes WHERE id = $1`, id)
|
||||
})
|
||||
}
|
||||
|
||||
func TestQuiz_Validation(t *testing.T) {
|
||||
svc, pg := newQuizSvc(t)
|
||||
ctx := context.Background()
|
||||
admin := chatUser(t, pg, "Admin")
|
||||
|
||||
cases := map[string]struct {
|
||||
d QuizDraft
|
||||
want error
|
||||
}{
|
||||
"no title": {QuizDraft{Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}, ErrQuizTitle},
|
||||
"no questions": {QuizDraft{Title: "T"}, ErrQuizNoQuestions},
|
||||
"one option": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", 0, "a")}}, ErrQuizOptionCount},
|
||||
"six options": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", 0, "a", "b", "c", "d", "e", "f")}}, ErrQuizOptionCount},
|
||||
"no correct": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", -1, "a", "b")}}, ErrQuizOneCorrect},
|
||||
"blank prompt": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q(" ", 0, "a", "b")}}, ErrQuizPrompt},
|
||||
"bad target url": {QuizDraft{Title: "T", TargetURL: "/letters/x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}, ErrQuizTarget},
|
||||
}
|
||||
for name, c := range cases {
|
||||
if _, err := svc.Create(ctx, admin, c.d); !errors.Is(err, c.want) {
|
||||
t.Errorf("%s: err = %v, want %v", name, err, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
two := q("p", 1, "a", "b")
|
||||
two.Options[0].IsCorrect = true // two correct
|
||||
if _, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{two}}); !errors.Is(err, ErrQuizOneCorrect) {
|
||||
t.Errorf("two correct: %v", err)
|
||||
}
|
||||
// …which is exactly what a multi question allows, but it needs one.
|
||||
two.Multi = true
|
||||
if quiz, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{two}}); err != nil {
|
||||
t.Errorf("multi with two correct: %v", err)
|
||||
} else {
|
||||
cleanupQuiz(t, pg, quiz.ID)
|
||||
}
|
||||
none := q("p", -1, "a", "b")
|
||||
none.Multi = true
|
||||
if _, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{none}}); !errors.Is(err, ErrQuizNoCorrect) {
|
||||
t.Errorf("multi with none correct: %v, want ErrQuizNoCorrect", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A multi question is scored correct only on the exact set of correct
|
||||
// options: a subset or an extra wrong pick both miss.
|
||||
func TestQuiz_MultiChoice(t *testing.T) {
|
||||
svc, pg := newQuizSvc(t)
|
||||
ctx := context.Background()
|
||||
admin := chatUser(t, pg, "Admin")
|
||||
|
||||
multi := func(prompt string) repository.QuestionInput {
|
||||
x := q(prompt, 0, "right A", "right B", "wrong")
|
||||
x.Options[1].IsCorrect = true
|
||||
x.Multi = true
|
||||
return x
|
||||
}
|
||||
quiz, err := svc.Create(ctx, admin, QuizDraft{
|
||||
Title: "Select all",
|
||||
Questions: []repository.QuestionInput{multi("exact"), multi("subset"), multi("extra"), q("single", 0, "a", "b")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupQuiz(t, pg, quiz.ID)
|
||||
if _, err := svc.SetPublished(ctx, quiz.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
full, _ := svc.GetAdmin(ctx, quiz.ID)
|
||||
if !full.Questions[0].Multi || full.Questions[3].Multi {
|
||||
t.Fatal("multi flag didn't round-trip")
|
||||
}
|
||||
opt := func(qi, oi int) string { return full.Questions[qi].Options[oi].ID }
|
||||
|
||||
user := chatUser(t, pg, "U")
|
||||
play, err := svc.Start(ctx, user, quiz.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !play.Question.Multi {
|
||||
t.Error("the player isn't told the question takes several answers")
|
||||
}
|
||||
// Nothing chosen is refused.
|
||||
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{}); !errors.Is(err, repository.ErrQuizChoiceCount) {
|
||||
t.Errorf("empty answer: %v, want ErrQuizChoiceCount", err)
|
||||
}
|
||||
steps := [][]string{
|
||||
{opt(0, 1), opt(0, 0), opt(0, 0)}, // exact set, any order, duplicate ignored
|
||||
{opt(1, 0)}, // subset
|
||||
{opt(2, 0), opt(2, 1), opt(2, 2)}, // every option, one wrong
|
||||
}
|
||||
for _, ids := range steps {
|
||||
if play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, ids); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// A single-answer question still takes exactly one.
|
||||
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{opt(3, 0), opt(3, 1)}); !errors.Is(err, repository.ErrQuizChoiceCount) {
|
||||
t.Errorf("two picks on a single question: %v, want ErrQuizChoiceCount", err)
|
||||
}
|
||||
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{opt(3, 0)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := svc.Result(ctx, user, quiz.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := []bool{}
|
||||
for _, it := range res.Items {
|
||||
got = append(got, it.IsCorrect)
|
||||
}
|
||||
if res.Score != 2 || !got[0] || got[1] || got[2] || !got[3] {
|
||||
t.Errorf("score %d, per question %v; want 2, [true false false true]", res.Score, got)
|
||||
}
|
||||
if n := len(res.Items[0].ChosenOptionIDs); n != 2 {
|
||||
t.Errorf("exact answer stored %d options, want 2 (duplicate dropped)", n)
|
||||
}
|
||||
|
||||
stats, err := svc.Responses(ctx, quiz.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Every option of the "extra" question was picked once.
|
||||
for _, o := range stats[2].Options {
|
||||
if o.Picks != 1 {
|
||||
t.Errorf("option %q picks = %d, want 1", o.Body, o.Picks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuiz_TakingFlow(t *testing.T) {
|
||||
svc, pg := newQuizSvc(t)
|
||||
ctx := context.Background()
|
||||
admin, user, other := chatUser(t, pg, "Admin"), chatUser(t, pg, "U"), chatUser(t, pg, "O")
|
||||
|
||||
quiz, err := svc.Create(ctx, admin, QuizDraft{
|
||||
Title: "Bg basics",
|
||||
Questions: []repository.QuestionInput{
|
||||
withExplanation(q("Who spoke the *Gītā*?", 0, "Kṛṣṇa", "Arjuna", "Sañjaya"), "Kṛṣṇa, to Arjuna."),
|
||||
q("Where?", 1, "Mathurā", "Kurukṣetra"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanupQuiz(t, pg, quiz.ID)
|
||||
|
||||
// Drafts aren't reachable.
|
||||
if _, err := svc.Start(ctx, user, quiz.ID); !errors.Is(err, ErrQuizUnpublished) {
|
||||
t.Fatalf("starting a draft: %v", err)
|
||||
}
|
||||
if _, err := svc.SetPublished(ctx, quiz.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
play, err := svc.Start(ctx, user, quiz.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if play.Question == nil || play.Total != 2 || play.Answered != 0 {
|
||||
t.Fatalf("first step = %+v", play)
|
||||
}
|
||||
// The correct answer never reaches someone mid-attempt — nor the
|
||||
// explanation, which discusses it.
|
||||
if play.Question.Explanation != "" {
|
||||
t.Error("explanation leaked to a user mid-attempt")
|
||||
}
|
||||
for _, o := range play.Question.Options {
|
||||
if o.IsCorrect != nil {
|
||||
t.Fatal("is_correct leaked to a user mid-attempt")
|
||||
}
|
||||
}
|
||||
// Nor can they read a result yet.
|
||||
if _, err := svc.Result(ctx, user, quiz.ID); !errors.Is(err, repository.ErrQuizNotFinished) {
|
||||
t.Errorf("result mid-attempt: %v, want ErrQuizNotFinished", err)
|
||||
}
|
||||
|
||||
card, _ := svc.Card(ctx, user, quiz.ID)
|
||||
if card.My.State != model.QuizInProgress {
|
||||
t.Errorf("state after start = %s, want in_progress", card.My.State)
|
||||
}
|
||||
|
||||
// Answering out of order (a question not yet served) is refused.
|
||||
full, _ := svc.GetAdmin(ctx, quiz.ID)
|
||||
second := full.Questions[1]
|
||||
if _, err := svc.Answer(ctx, user, quiz.ID, second.ID, []string{second.Options[0].ID}); !errors.Is(err, repository.ErrQuizNotCurrent) {
|
||||
t.Errorf("answering ahead: %v, want ErrQuizNotCurrent", err)
|
||||
}
|
||||
// An option from another question is refused.
|
||||
if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{second.Options[0].ID}); !errors.Is(err, repository.ErrQuizBadOption) {
|
||||
t.Errorf("foreign option: %v, want ErrQuizBadOption", err)
|
||||
}
|
||||
|
||||
// Q1 right, Q2 wrong.
|
||||
play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{full.Questions[0].Options[0].ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Can't change an answer.
|
||||
if _, err := svc.Answer(ctx, user, quiz.ID, full.Questions[0].ID, []string{full.Questions[0].Options[1].ID}); !errors.Is(err, repository.ErrQuizAlreadyAnswer) {
|
||||
t.Errorf("re-answering: %v, want ErrQuizAlreadyAnswer", err)
|
||||
}
|
||||
play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{second.Options[0].ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !play.Done {
|
||||
t.Fatalf("after the last answer: %+v", play)
|
||||
}
|
||||
|
||||
res, err := svc.Result(ctx, user, quiz.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Score != 1 || res.Total != 2 || res.CompletedAt == nil {
|
||||
t.Errorf("result = score %d/%d completed %v", res.Score, res.Total, res.CompletedAt)
|
||||
}
|
||||
for _, it := range res.Items {
|
||||
if it.ResponseMs == nil {
|
||||
t.Error("a response time wasn't recorded")
|
||||
}
|
||||
if len(it.ChosenOptionIDs) != 1 {
|
||||
t.Error("the chosen option wasn't recorded")
|
||||
}
|
||||
}
|
||||
if !res.Items[0].IsCorrect || res.Items[1].IsCorrect {
|
||||
t.Error("per-question correctness is wrong")
|
||||
}
|
||||
|
||||
// Only your own result: someone who never took it has none.
|
||||
if _, err := svc.Result(ctx, other, quiz.ID); !errors.Is(err, repository.ErrQuizNotStarted) {
|
||||
t.Errorf("another user's result: %v, want ErrQuizNotStarted", err)
|
||||
}
|
||||
|
||||
// Admin sees the participant and the per-question picks.
|
||||
parts, err := svc.Participants(ctx, quiz.ID)
|
||||
if err != nil || len(parts) != 1 || parts[0].Score != 1 || parts[0].Answered != 2 {
|
||||
t.Errorf("participants = %+v, %v", parts, err)
|
||||
}
|
||||
stats, err := svc.Responses(ctx, quiz.ID)
|
||||
if err != nil || len(stats) != 2 || stats[0].Correct != 1 || stats[1].Correct != 0 {
|
||||
t.Errorf("responses = %+v, %v", stats, err)
|
||||
}
|
||||
|
||||
// Taken, so questions are frozen — but the title can still change.
|
||||
if _, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); !errors.Is(err, ErrQuizLocked) {
|
||||
t.Errorf("editing questions after attempts: %v, want ErrQuizLocked", err)
|
||||
}
|
||||
if upd, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "Bhagavad-gītā basics"}); err != nil || len(upd.Questions) != 2 {
|
||||
t.Errorf("retitling a taken quiz: %v (questions %d)", err, len(upd.Questions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeContentPath(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"https://prabhupadasays.org/books/bg/2/?x=1#v3": "/books/bg/2",
|
||||
"/transcriptions/720720sb-london": "/transcriptions/720720sb-london",
|
||||
"books/bg/2": "/books/bg/2",
|
||||
"": "",
|
||||
"/": "",
|
||||
} {
|
||||
if got := NormalizeContentPath(in); got != want {
|
||||
t.Errorf("NormalizeContentPath(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// defaultAdminURL is where the welcome email sends new staff when ADMIN_URL
|
||||
// isn't configured (local development leaves it empty).
|
||||
const defaultAdminURL = "https://admin.prabhupadasays.org"
|
||||
|
||||
// sendStaffWelcome tells someone they've been given admin access, and as
|
||||
// what. Fire-and-forget, like the other notification emails: the grant is
|
||||
// already committed, so a mail failure is logged, never surfaced.
|
||||
func (s *StaffService) sendStaffWelcome(m *model.StaffMember) {
|
||||
if s.mail == nil {
|
||||
return
|
||||
}
|
||||
if m.Email == nil || strings.TrimSpace(*m.Email) == "" {
|
||||
s.logger.Info("staff granted; no email on file, skipping welcome",
|
||||
zap.String("user_id", m.UserID))
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := staffWelcomeEmailTpl.Execute(&buf, map[string]any{
|
||||
"RecipientName": strings.TrimSpace(m.Name),
|
||||
"RoleName": m.RoleName,
|
||||
"AdminURL": s.adminURL,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render staff welcome email failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
to := strings.TrimSpace(*m.Email)
|
||||
go func() {
|
||||
if err := s.mail.Send(to, "You've been promoted to "+m.RoleName+" — Prabhupāda Says", buf.String()); err != nil {
|
||||
s.logger.Warn("staff welcome email send failed",
|
||||
zap.String("user_id", m.UserID), zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// staffWelcomeEmailTpl — same styling as the other account emails. The role
|
||||
// name is admin-entered (custom roles) and auto-escaped by html/template.
|
||||
var staffWelcomeEmailTpl = template.Must(template.New("staffWelcome").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||
|
||||
<p>You have been promoted to <strong>{{.RoleName}}</strong> on Prabhupāda Says.</p>
|
||||
|
||||
<p>Log in with your email and password at
|
||||
<a href="{{.AdminURL}}" style="color:#9a6b2f;font-weight:600">{{.AdminURL}}</a>.</p>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.AdminURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open the admin panel</a>
|
||||
</p>
|
||||
|
||||
<p>For any queries, contact your co-ordinator.</p>
|
||||
|
||||
<p style="margin-top:1.5em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
@@ -0,0 +1,256 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/rbac"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrStaffSelf = errors.New("you can't change your own staff access")
|
||||
ErrLastSuperadmin = errors.New("there must always be at least one active superadmin")
|
||||
ErrRoleSystem = errors.New("system roles can't be edited or deleted")
|
||||
ErrRoleBadPermission = errors.New("unknown permission")
|
||||
ErrRoleName = errors.New("a role needs a name")
|
||||
ErrRoleNoPermissions = errors.New("a role needs at least one permission")
|
||||
)
|
||||
|
||||
// StaffService manages who can use the admin panel and with what powers.
|
||||
// Every method here is reachable only with rbac.ManageStaff — superadmins.
|
||||
type StaffService struct {
|
||||
staff *repository.StaffRepo
|
||||
// mail tells newly added staff about their access; nil sends nothing.
|
||||
mail mailer.Mailer
|
||||
logger *zap.Logger
|
||||
adminURL string
|
||||
}
|
||||
|
||||
// NewStaffService wires staff management. adminURL is the panel's address for
|
||||
// the welcome email; empty falls back to the production panel.
|
||||
func NewStaffService(staff *repository.StaffRepo, mail mailer.Mailer, logger *zap.Logger, adminURL string) *StaffService {
|
||||
if adminURL == "" {
|
||||
adminURL = defaultAdminURL
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &StaffService{staff: staff, mail: mail, logger: logger, adminURL: adminURL}
|
||||
}
|
||||
|
||||
// Me describes the caller, for the panel to decide what to show.
|
||||
type Me struct {
|
||||
RoleKey string `json:"role_key"`
|
||||
RoleName string `json:"role_name"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
func (s *StaffService) List(ctx context.Context) ([]model.StaffMember, error) {
|
||||
return s.staff.ListStaff(ctx)
|
||||
}
|
||||
|
||||
// isSuperadminRole reports whether a role id is the seeded superadmin.
|
||||
func (s *StaffService) isSuperadminRole(ctx context.Context, roleID string) (bool, error) {
|
||||
ro, err := s.staff.Role(ctx, roleID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return ro.Key == repository.SuperadminKey, nil
|
||||
}
|
||||
|
||||
// guardSuperadminLoss refuses a change that would take an ACTIVE superadmin
|
||||
// out of that state if they're the last one — demoting, revoking or blocking
|
||||
// them would leave no one able to manage staff, with no way back from the UI.
|
||||
func (s *StaffService) guardSuperadminLoss(ctx context.Context, target *model.StaffMember) error {
|
||||
if target.RoleKey != repository.SuperadminKey || target.BlockedAt != nil {
|
||||
return nil
|
||||
}
|
||||
n, err := s.staff.ActiveSuperadmins(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
return ErrLastSuperadmin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Grant gives an account a role, by handle.
|
||||
func (s *StaffService) Grant(ctx context.Context, actorID, handle, roleID string) (*model.StaffMember, error) {
|
||||
userID, err := s.staff.UserIDByHandle(ctx, strings.TrimPrefix(strings.TrimSpace(handle), "@"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID == actorID {
|
||||
return nil, ErrStaffSelf
|
||||
}
|
||||
if _, err := s.staff.Role(ctx, roleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Granting to someone who is already staff is a role change, and must
|
||||
// pass the same last-superadmin check.
|
||||
if existing, err := s.staff.StaffMember(ctx, userID); err == nil {
|
||||
return s.ChangeRole(ctx, actorID, existing.UserID, roleID)
|
||||
}
|
||||
if err := s.staff.SetRole(ctx, userID, &roleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := s.staff.StaffMember(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Only a NEW staff member is welcomed; role changes above stay quiet.
|
||||
s.sendStaffWelcome(m)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ChangeRole moves a staff member to another role.
|
||||
func (s *StaffService) ChangeRole(ctx context.Context, actorID, targetID, roleID string) (*model.StaffMember, error) {
|
||||
if targetID == actorID {
|
||||
return nil, ErrStaffSelf
|
||||
}
|
||||
target, err := s.staff.StaffMember(ctx, targetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toSuper, err := s.isSuperadminRole(ctx, roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !toSuper {
|
||||
if err := s.guardSuperadminLoss(ctx, target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.staff.SetRole(ctx, targetID, &roleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.staff.StaffMember(ctx, targetID)
|
||||
}
|
||||
|
||||
// Revoke removes a staff member's access entirely.
|
||||
func (s *StaffService) Revoke(ctx context.Context, actorID, targetID string) error {
|
||||
if targetID == actorID {
|
||||
return ErrStaffSelf
|
||||
}
|
||||
target, err := s.staff.StaffMember(ctx, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.guardSuperadminLoss(ctx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.staff.SetRole(ctx, targetID, nil)
|
||||
}
|
||||
|
||||
// SetBlocked suspends or restores a staff member's panel access, keeping their
|
||||
// role so restoring them is one action rather than a re-grant.
|
||||
func (s *StaffService) SetBlocked(ctx context.Context, actorID, targetID string, blocked bool) (*model.StaffMember, error) {
|
||||
if targetID == actorID {
|
||||
return nil, ErrStaffSelf
|
||||
}
|
||||
target, err := s.staff.StaffMember(ctx, targetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blocked {
|
||||
if err := s.guardSuperadminLoss(ctx, target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.staff.SetBlocked(ctx, targetID, blocked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.staff.StaffMember(ctx, targetID)
|
||||
}
|
||||
|
||||
// ── Roles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *StaffService) Roles(ctx context.Context) ([]model.AdminRole, error) {
|
||||
return s.staff.ListRoles(ctx)
|
||||
}
|
||||
|
||||
// cleanPermissions validates a custom role's permissions against the catalog.
|
||||
// The wildcard and admins:manage aren't in the catalog, so they are refused
|
||||
// here — a custom role can never become a second superadmin.
|
||||
func cleanPermissions(perms []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, p := range perms {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || seen[p] {
|
||||
continue
|
||||
}
|
||||
if !rbac.Valid(p) {
|
||||
return nil, ErrRoleBadPermission
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, ErrRoleNoPermissions
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var nonKey = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
func roleKeyFor(name string) string {
|
||||
k := strings.Trim(nonKey.ReplaceAllString(strings.ToLower(name), "_"), "_")
|
||||
if len(k) > 40 {
|
||||
k = k[:40]
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func (s *StaffService) CreateRole(ctx context.Context, name, description string, perms []string) (*model.AdminRole, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
key := roleKeyFor(name)
|
||||
if name == "" || key == "" {
|
||||
return nil, ErrRoleName
|
||||
}
|
||||
clean, err := cleanPermissions(perms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.staff.CreateRole(ctx, key, name, strings.TrimSpace(description), clean)
|
||||
}
|
||||
|
||||
func (s *StaffService) UpdateRole(ctx context.Context, id, name, description string, perms []string) (*model.AdminRole, error) {
|
||||
ro, err := s.staff.Role(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ro.IsSystem {
|
||||
return nil, ErrRoleSystem
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, ErrRoleName
|
||||
}
|
||||
clean, err := cleanPermissions(perms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.staff.UpdateRole(ctx, id, name, strings.TrimSpace(description), clean); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.staff.Role(ctx, id)
|
||||
}
|
||||
|
||||
func (s *StaffService) DeleteRole(ctx context.Context, id string) error {
|
||||
ro, err := s.staff.Role(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ro.IsSystem {
|
||||
return ErrRoleSystem
|
||||
}
|
||||
return s.staff.DeleteRole(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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):
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user