feat: list and show quizzes over MCP for their owner
- quizzes_list_quizzes and quizzes_show_quiz bound to GET /api/v1/quizzes
and /api/v1/quizzes/{slug} behind user.api_token, the per-user
golem15.quizzes.manage throttle, a scope and the access_quizzes permission
- the permission is grantable through pact.HasFrontendPermissions
- owner JSON shapes with solutions in manage_json.go, never used by a
student route
This commit is contained in:
97
classes/manage.go
Normal file
97
classes/manage.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package classes
|
||||
|
||||
// Owner-side management behind the manage routes under /api/v1/quizzes,
|
||||
// which the MCP quiz tools bind. There is no owner column: a user whose
|
||||
// groups grant golem15.quizzes.access_quizzes manages every quiz, as in the
|
||||
// admin (A7). Quizzes are addressed by slug.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ManagedQuizRow is one quiz of the owner list with its question count and
|
||||
// the number of completed attempts (of every user).
|
||||
type ManagedQuizRow struct {
|
||||
Quiz models.Quiz
|
||||
QuestionCount int
|
||||
AttemptsCount int
|
||||
}
|
||||
|
||||
// ListManagedQuizzes returns every quiz, published or not, optionally only
|
||||
// those whose context equals quizContext, ordered by context (quizzes
|
||||
// without one last), title and id.
|
||||
func ListManagedQuizzes(ctx context.Context, db *gorm.DB, quizContext string) ([]ManagedQuizRow, error) {
|
||||
db = db.WithContext(ctx)
|
||||
q := db.Model(&models.Quiz{})
|
||||
if quizContext != "" {
|
||||
q = q.Where("context = ?", quizContext)
|
||||
}
|
||||
var quizzes []models.Quiz
|
||||
if err := q.Order("context ASC NULLS LAST, title ASC, id ASC").Find(&quizzes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ManagedQuizRow, 0, len(quizzes))
|
||||
if len(quizzes) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
ids := make([]uint, len(quizzes))
|
||||
for i, quiz := range quizzes {
|
||||
ids[i] = quiz.ID
|
||||
}
|
||||
type countRow struct {
|
||||
QuizID uint
|
||||
N int
|
||||
}
|
||||
var questions, attempts []countRow
|
||||
if err := db.Model(&models.Question{}).Select("quiz_id, COUNT(*) AS n").
|
||||
Where("quiz_id IN ?", ids).Group("quiz_id").Scan(&questions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Model(&models.Attempt{}).Select("quiz_id, COUNT(*) AS n").
|
||||
Where("quiz_id IN ? AND completed_at IS NOT NULL", ids).Group("quiz_id").Scan(&attempts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
questionsBy := map[uint]int{}
|
||||
for _, c := range questions {
|
||||
questionsBy[c.QuizID] = c.N
|
||||
}
|
||||
attemptsBy := map[uint]int{}
|
||||
for _, c := range attempts {
|
||||
attemptsBy[c.QuizID] = c.N
|
||||
}
|
||||
for _, quiz := range quizzes {
|
||||
out = append(out, ManagedQuizRow{Quiz: quiz, QuestionCount: questionsBy[quiz.ID], AttemptsCount: attemptsBy[quiz.ID]})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ManagedQuiz loads a quiz, published or not, with every question in sort
|
||||
// order and every answer of each, solutions included. A missing slug is
|
||||
// ErrNotFound.
|
||||
func ManagedQuiz(ctx context.Context, db *gorm.DB, slug string) (*models.Quiz, error) {
|
||||
var quiz models.Quiz
|
||||
err := db.WithContext(ctx).
|
||||
Preload("Questions", func(tx *gorm.DB) *gorm.DB { return tx.Order("sort_order ASC, id ASC") }).
|
||||
Preload("Questions.Answers", func(tx *gorm.DB) *gorm.DB { return tx.Order("sort_order ASC, id ASC") }).
|
||||
Where("slug = ?", slug).Take(&quiz).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &quiz, nil
|
||||
}
|
||||
|
||||
// CompletedAttemptCount counts the completed attempts of every user on a
|
||||
// quiz.
|
||||
func CompletedAttemptCount(ctx context.Context, db *gorm.DB, quizID uint) (int, error) {
|
||||
var n int64
|
||||
err := db.WithContext(ctx).Model(&models.Attempt{}).
|
||||
Where("quiz_id = ? AND completed_at IS NOT NULL", quizID).Count(&n).Error
|
||||
return int(n), err
|
||||
}
|
||||
89
controllers/api/manage.go
Normal file
89
controllers/api/manage.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package api
|
||||
|
||||
// The owner manage routes under /api/v1/quizzes, behind user.api_token, the
|
||||
// golem15.quizzes.manage throttle, a token scope and the access_quizzes
|
||||
// permission. The MCP quiz tools bind them. Answers use the owner shapes of
|
||||
// manage_json.go, which carry the solutions.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// manageDB returns the application database, or answers 503 as the student
|
||||
// routes do without one.
|
||||
func (h *Handlers) manageDB(w http.ResponseWriter) (*gorm.DB, bool) {
|
||||
var gdb *gorm.DB
|
||||
if h.App != nil {
|
||||
gdb, _ = h.App.Lookup[*gorm.DB]()
|
||||
}
|
||||
if gdb == nil {
|
||||
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "Database unavailable"})
|
||||
return nil, false
|
||||
}
|
||||
return gdb, true
|
||||
}
|
||||
|
||||
// writeManaged answers {data: data}, or maps err: ErrNotFound is a 404,
|
||||
// ErrQuizExists a 409, anything else an opaque 500.
|
||||
func writeManaged(w http.ResponseWriter, status int, data any, err error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
wire.WriteJSON(w, status, map[string]any{"data": data})
|
||||
case errors.Is(err, classes.ErrNotFound):
|
||||
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": true, "message": "Quiz not found."})
|
||||
case errors.Is(err, classes.ErrQuizExists):
|
||||
wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": true, "message": "A quiz with this slug already exists."})
|
||||
default:
|
||||
wire.WriteOpaque500(w)
|
||||
}
|
||||
}
|
||||
|
||||
// ManageListQuizzes answers GET /api/v1/quizzes?context=: every quiz,
|
||||
// published or not, with its question and completed-attempt counts.
|
||||
func (h *Handlers) ManageListQuizzes(w http.ResponseWriter, r *http.Request) {
|
||||
db, ok := h.manageDB(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := classes.ListManagedQuizzes(r.Context(), db, strings.TrimSpace(r.URL.Query().Get("context")))
|
||||
if err != nil {
|
||||
writeManaged(w, 0, nil, err)
|
||||
return
|
||||
}
|
||||
out := make([]managedQuizJSON, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, managedQuizRow(row))
|
||||
}
|
||||
writeManaged(w, http.StatusOK, out, nil)
|
||||
}
|
||||
|
||||
// ManageShowQuiz answers GET /api/v1/quizzes/{slug}: the quiz with every
|
||||
// question and answer, solutions included.
|
||||
func (h *Handlers) ManageShowQuiz(w http.ResponseWriter, r *http.Request) {
|
||||
db, ok := h.manageDB(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.writeManagedQuiz(w, r, db, http.StatusOK, r.PathValue("slug"))
|
||||
}
|
||||
|
||||
// writeManagedQuiz answers the quiz with its questions and solutions.
|
||||
func (h *Handlers) writeManagedQuiz(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, slug string) {
|
||||
quiz, err := classes.ManagedQuiz(r.Context(), db, slug)
|
||||
if err != nil {
|
||||
writeManaged(w, 0, nil, err)
|
||||
return
|
||||
}
|
||||
attempts, err := classes.CompletedAttemptCount(r.Context(), db, quiz.ID)
|
||||
if err != nil {
|
||||
writeManaged(w, 0, nil, err)
|
||||
return
|
||||
}
|
||||
writeManaged(w, status, managedQuizDetail(*quiz, attempts), nil)
|
||||
}
|
||||
116
controllers/api/manage_json.go
Normal file
116
controllers/api/manage_json.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package api
|
||||
|
||||
// The owner shapes of the manage routes. They carry the solutions
|
||||
// (is_correct, explanations, answer_text) and are never used by a student
|
||||
// route; the student shapes live in json.go. Built field by field, so a new
|
||||
// column never leaks.
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
)
|
||||
|
||||
type managedQuizJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Context *string `json:"context"`
|
||||
Mode string `json:"mode"`
|
||||
Navigation string `json:"navigation"`
|
||||
MaxAttempts *int `json:"max_attempts"`
|
||||
TimeLimitSeconds *int `json:"time_limit_seconds"`
|
||||
PassingScore int `json:"passing_score"`
|
||||
ShuffleQuestions bool `json:"shuffle_questions"`
|
||||
RoundSize *int `json:"round_size"`
|
||||
IsPublished bool `json:"is_published"`
|
||||
QuestionCount int `json:"question_count"`
|
||||
AttemptsCount int `json:"attempts_count"`
|
||||
CreatedAt wire.Time `json:"created_at"`
|
||||
UpdatedAt wire.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type managedQuizDetailJSON struct {
|
||||
managedQuizJSON
|
||||
Questions []managedQuestionJSON `json:"questions"`
|
||||
}
|
||||
|
||||
type managedQuestionJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Category *string `json:"category"`
|
||||
AnswerText *string `json:"answer_text"`
|
||||
Explanation *string `json:"explanation"`
|
||||
Points int `json:"points"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Answers []managedAnswerJSON `json:"answers"`
|
||||
}
|
||||
|
||||
type managedAnswerJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
Explanation *string `json:"explanation"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func managedQuiz(q models.Quiz, questions, attempts int) managedQuizJSON {
|
||||
return managedQuizJSON{
|
||||
ID: q.ID,
|
||||
Slug: q.Slug,
|
||||
Title: q.Title,
|
||||
Description: q.Description,
|
||||
Context: q.Context,
|
||||
Mode: quizMode(&q),
|
||||
Navigation: quizNavigation(&q),
|
||||
MaxAttempts: q.MaxAttempts,
|
||||
TimeLimitSeconds: q.TimeLimitSeconds,
|
||||
PassingScore: q.PassingScore,
|
||||
ShuffleQuestions: q.ShuffleQuestions,
|
||||
RoundSize: q.RoundSize,
|
||||
IsPublished: q.IsPublished,
|
||||
QuestionCount: questions,
|
||||
AttemptsCount: attempts,
|
||||
CreatedAt: wireTime(q.CreatedAt),
|
||||
UpdatedAt: wireTime(q.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func managedQuizRow(row classes.ManagedQuizRow) managedQuizJSON {
|
||||
return managedQuiz(row.Quiz, row.QuestionCount, row.AttemptsCount)
|
||||
}
|
||||
|
||||
// managedQuizDetail is a quiz loaded by classes.ManagedQuiz with its
|
||||
// questions; attempts is its completed-attempt count.
|
||||
func managedQuizDetail(q models.Quiz, attempts int) managedQuizDetailJSON {
|
||||
out := managedQuizDetailJSON{
|
||||
managedQuizJSON: managedQuiz(q, len(q.Questions), attempts),
|
||||
Questions: make([]managedQuestionJSON, 0, len(q.Questions)),
|
||||
}
|
||||
for _, question := range q.Questions {
|
||||
out.Questions = append(out.Questions, managedQuestion(question))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func managedQuestion(q models.Question) managedQuestionJSON {
|
||||
out := managedQuestionJSON{
|
||||
ID: q.ID,
|
||||
Text: q.Text,
|
||||
Type: q.Type,
|
||||
Category: q.Category,
|
||||
AnswerText: q.AnswerText,
|
||||
Explanation: q.Explanation,
|
||||
Points: q.Points,
|
||||
SortOrder: q.SortOrder,
|
||||
Answers: make([]managedAnswerJSON, 0, len(q.Answers)),
|
||||
}
|
||||
for _, a := range q.Answers {
|
||||
out.Answers = append(out.Answers, managedAnswerJSON{
|
||||
ID: a.ID, Text: a.Text, IsCorrect: a.IsCorrect, Explanation: a.Explanation, SortOrder: a.SortOrder,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
22
controllers/api/manage_test.go
Normal file
22
controllers/api/manage_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
)
|
||||
|
||||
// TestManageHandlersWithoutDatabase: every manage handler answers 503
|
||||
// when the application has no database.
|
||||
func TestManageHandlersWithoutDatabase(t *testing.T) {
|
||||
h := New(backpack.New(nil))
|
||||
for name, fn := range map[string]http.HandlerFunc{
|
||||
"list": h.ManageListQuizzes, "show": h.ManageShowQuiz,
|
||||
} {
|
||||
code, _ := call(t, fn, "GET", "/", "", 7, map[string]string{"slug": "x"})
|
||||
if code != http.StatusServiceUnavailable {
|
||||
t.Errorf("%s without a database = %d, want 503", name, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
14
mcp.go
Normal file
14
mcp.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package quizzes
|
||||
|
||||
import "git.golem15.com/golem15/summercms/modules/pact"
|
||||
|
||||
var _ pact.HasMCP = (*Plugin)(nil)
|
||||
|
||||
// MCP declares the owner's quiz tools (exposed as quizzes_*). Every tool
|
||||
// lists the golem15.quizzes.access_quizzes permission, so a user whose
|
||||
// groups lack it never lists them, and every route the tools bind checks
|
||||
// it again (D-13). The plugin still requires only golem15.user and names
|
||||
// no OAuth server.
|
||||
func (p *Plugin) MCP() pact.MCP {
|
||||
return pact.MCP{Tools: quizTools()}
|
||||
}
|
||||
53
mcp_quizzes.go
Normal file
53
mcp_quizzes.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package quizzes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// The sentences every quiz tool description ends with (D-13).
|
||||
const (
|
||||
needsRead = " Needs scope read and the golem15.quizzes.access_quizzes permission."
|
||||
needsWrite = " Needs scope write and the golem15.quizzes.access_quizzes permission."
|
||||
)
|
||||
|
||||
// quizPermissions filters the listing: only users whose groups grant the
|
||||
// quizzes permission see the quiz tools.
|
||||
var quizPermissions = []string{permQuizzes}
|
||||
|
||||
// yes is the true the tool annotations point at.
|
||||
var yes = true
|
||||
|
||||
// listQuizzesInput is the input of quizzes_list_quizzes.
|
||||
type listQuizzesInput struct {
|
||||
Context string `json:"context,omitempty" query:"context" jsonschema:"only quizzes whose context label equals this value exactly; leave out for every quiz"`
|
||||
}
|
||||
|
||||
// showQuizInput is the input of quizzes_show_quiz.
|
||||
type showQuizInput struct {
|
||||
Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug (lowercase letters, digits and hyphens), as listed by quizzes_list_quizzes"`
|
||||
}
|
||||
|
||||
func quizTools() []pact.MCPTool {
|
||||
return []pact.MCPTool{
|
||||
{
|
||||
Name: "list_quizzes",
|
||||
Title: "List quizzes",
|
||||
Description: "Lists every quiz, published or not, ordered by context (quizzes without one last) and title: id, slug, title, description, context, mode (learning or test), navigation (free or one_way), max_attempts, time_limit_seconds, passing_score, shuffle_questions, round_size, is_published, question_count, attempts_count (completed attempts of all students), created_at and updated_at. context filters by the exact context label." + needsRead,
|
||||
Method: http.MethodGet,
|
||||
Path: ManagePrefix,
|
||||
Input: listQuizzesInput{},
|
||||
Permissions: quizPermissions,
|
||||
},
|
||||
{
|
||||
Name: "show_quiz",
|
||||
Title: "Show a quiz with its solutions",
|
||||
Description: "Returns one quiz with the fields of quizzes_list_quizzes and its questions in order: id, text, type (single, multiple or self_check), category, answer_text, explanation, points, sort_order and answers [{id, text, is_correct, explanation, sort_order}]. This is the owner view: it includes the solutions (is_correct, explanations and answer_text), which students only see after answering or finishing." + needsRead,
|
||||
Method: http.MethodGet,
|
||||
Path: ManagePrefix + "/{slug}",
|
||||
Input: showQuizInput{},
|
||||
Permissions: quizPermissions,
|
||||
},
|
||||
}
|
||||
}
|
||||
184
mcp_test.go
Normal file
184
mcp_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package quizzes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
_ "git.golem15.com/golem15/sm-user-plugin"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/pier"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
// mountHost adapts a built surf router to pier.Host for a compile-only
|
||||
// mount: pier compiles every declared tool against the real route table.
|
||||
type mountHost struct{ r *surf.Router }
|
||||
|
||||
func (h mountHost) Routes() []pier.RouteInfo {
|
||||
var out []pier.RouteInfo
|
||||
for _, ri := range h.r.Routes() {
|
||||
out = append(out, pier.RouteInfo{Method: ri.Method, Pattern: ri.Pattern, PluginID: ri.PluginID, Middleware: ri.Middleware})
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (h mountHost) Handler(method, pattern string) (http.Handler, bool) {
|
||||
return h.r.Handler(method, pattern)
|
||||
}
|
||||
func (mountHost) MountStream(string, string, http.Handler, ...string) error { return nil }
|
||||
func (mountHost) MountJSON(string, string, http.Handler) error { return nil }
|
||||
func (h mountHost) Owner(method, path string) (string, bool) {
|
||||
for _, ri := range h.r.Routes() {
|
||||
if ri.Method == method && ri.Pattern == path {
|
||||
return ri.PluginID, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// mcpTestApp boots golem15.user and golem15.quizzes on a config without a
|
||||
// database.
|
||||
func mcpTestApp(t *testing.T) (*backpack.App, []party.Plugin) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"app.yaml": "name: quizzes-mcp\nlocale: en\nfallback_locale: en\nurl: https://quizzes.example.test\n",
|
||||
"http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 10485760\n",
|
||||
}
|
||||
for name, body := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{
|
||||
"SUMMER_ENV=testing",
|
||||
"SUMMER_GOLEM15__USER__JWT__SECRET=quizzes-test-only-user-jwt-secret-with-32",
|
||||
"SUMMER_ADMIN__JWT__SECRET=quizzes-test-only-admin-jwt-secret-with-32",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
plugins, err := party.Activate(app, []string{"golem15.user", "golem15.quizzes"})
|
||||
if err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
return app, plugins
|
||||
}
|
||||
|
||||
// pinnedTools is the sorted tool list of golem15.user plus this plugin.
|
||||
var pinnedTools = []string{
|
||||
"quizzes_list_quizzes",
|
||||
"quizzes_show_quiz",
|
||||
"user_me",
|
||||
}
|
||||
|
||||
// TestQuizzesMCP (D-13, D-14) mounts pier against golem15.user and
|
||||
// golem15.quizzes and pins the compiled tools; every quiz tool lists the
|
||||
// quizzes permission and says so, GET and DELETE tools carry no body, and
|
||||
// only import_quiz can carry is_published. Runs under -short.
|
||||
func TestQuizzesMCP(t *testing.T) {
|
||||
tools := (&Plugin{}).MCP().Tools
|
||||
|
||||
t.Run("compiles", func(t *testing.T) {
|
||||
app, plugins := mcpTestApp(t)
|
||||
router, err := surf.BuildRouter(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRouter: %v", err)
|
||||
}
|
||||
if err := app.Config.Set(pier.EnabledKey, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pier.Mount(mountHost{r: router}, app, plugins); err != nil {
|
||||
t.Fatalf("pier.Mount: %v", err)
|
||||
}
|
||||
p, ok := app.Lookup[*pier.Pier]()
|
||||
if !ok {
|
||||
t.Fatal("pier was not mounted")
|
||||
}
|
||||
if got := p.ToolNames(); !slices.Equal(got, pinnedTools) {
|
||||
t.Fatalf("compiled tools %v, want %v", got, pinnedTools)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("permissions", func(t *testing.T) {
|
||||
for _, tool := range tools {
|
||||
if !slices.Contains(tool.Permissions, permQuizzes) {
|
||||
t.Errorf("%s: Permissions %v lack %s", tool.Name, tool.Permissions, permQuizzes)
|
||||
}
|
||||
if !strings.Contains(tool.Description, "Needs scope") || !strings.Contains(tool.Description, "golem15.quizzes.access_quizzes") {
|
||||
t.Errorf("%s: description does not name its scope and permission: %q", tool.Name, tool.Description)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inputs", func(t *testing.T) {
|
||||
for _, tool := range tools {
|
||||
rt := reflect.TypeOf(tool.Input)
|
||||
for rt.Kind() == reflect.Pointer {
|
||||
rt = rt.Elem()
|
||||
}
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
f := rt.Field(i)
|
||||
switch {
|
||||
case f.Tag.Get("path") != "":
|
||||
if !strings.Contains(tool.Path, "{"+f.Tag.Get("path")+"}") {
|
||||
t.Errorf("%s.%s: path tag %q not in %s", tool.Name, f.Name, f.Tag.Get("path"), tool.Path)
|
||||
}
|
||||
case f.Tag.Get("query") != "":
|
||||
default:
|
||||
if tool.Method == http.MethodGet || tool.Method == http.MethodDelete {
|
||||
t.Errorf("%s.%s: a %s tool carries a body field", tool.Name, f.Name, tool.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
key, found := jsonKeyIn(rt, "is_published")
|
||||
if found != (tool.Name == "import_quiz") {
|
||||
t.Errorf("%s: has a field named %s = %v, want %v", tool.Name, key, found, tool.Name == "import_quiz")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("descriptions", func(t *testing.T) {
|
||||
for _, tool := range tools {
|
||||
if n := utf8.RuneCountInString(tool.Description); n > pier.MaxDescriptionRunes {
|
||||
t.Errorf("%s: description has %d runes, more than %d", tool.Name, n, pier.MaxDescriptionRunes)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// jsonKeyIn reports whether rt, or a struct it contains (through pointers,
|
||||
// slices and nested structs), has a field with the given JSON key.
|
||||
func jsonKeyIn(rt reflect.Type, key string) (string, bool) {
|
||||
seen := map[reflect.Type]bool{}
|
||||
var walk func(reflect.Type) bool
|
||||
walk = func(t reflect.Type) bool {
|
||||
for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Array {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t.Kind() != reflect.Struct || seen[t] {
|
||||
return false
|
||||
}
|
||||
seen[t] = true
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
||||
if name == key || (name == "" && strings.EqualFold(f.Name, strings.ReplaceAll(key, "_", ""))) {
|
||||
return true
|
||||
}
|
||||
if walk(f.Type) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return key, walk(rt)
|
||||
}
|
||||
42
plugin.go
42
plugin.go
@@ -4,14 +4,19 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/console"
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"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/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -28,6 +33,9 @@ var (
|
||||
_ pact.AdminAssets = (*Plugin)(nil)
|
||||
_ pact.HasPermissions = (*Plugin)(nil)
|
||||
_ pact.HasNavigation = (*Plugin)(nil)
|
||||
|
||||
_ pact.HasFrontendPermissions = (*Plugin)(nil)
|
||||
_ surf.BucketProvider = (*Plugin)(nil)
|
||||
)
|
||||
|
||||
//go:embed config
|
||||
@@ -115,11 +123,43 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
// Permissions declares the admin permissions.
|
||||
func (p *Plugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{
|
||||
{Code: "golem15.quizzes.access_quizzes", Tab: "golem15.quizzes::lang.plugin.name", Label: "golem15.quizzes::lang.permissions.access_quizzes"},
|
||||
{Code: permQuizzes, Tab: "golem15.quizzes::lang.plugin.name", Label: "golem15.quizzes::lang.permissions.access_quizzes"},
|
||||
{Code: "golem15.quizzes.access_attempts", Tab: "golem15.quizzes::lang.plugin.name", Label: "golem15.quizzes::lang.permissions.access_attempts"},
|
||||
}
|
||||
}
|
||||
|
||||
// FrontendPermissions declares the code the owner manage routes check (the
|
||||
// MCP quiz tools), so golem15.user's User Groups editor can grant it.
|
||||
// Declaring it grants nothing by itself.
|
||||
func (p *Plugin) FrontendPermissions() []pact.FrontendPermission {
|
||||
return []pact.FrontendPermission{{
|
||||
Code: permQuizzes,
|
||||
Label: "golem15.quizzes::lang.permissions.access_quizzes",
|
||||
Tab: "golem15.quizzes::lang.plugin.name",
|
||||
}}
|
||||
}
|
||||
|
||||
// Buckets declares the per-user rate limit of the owner manage routes:
|
||||
// building a quiz question by question is chatty, so 120 calls a minute.
|
||||
// A request without a user (never after user.api_token) keys by client IP.
|
||||
func (p *Plugin) Buckets() map[string]surf.Bucket {
|
||||
trusted := surf.TrustedProxies(nil)
|
||||
if p != nil && p.app != nil {
|
||||
trusted = surf.TrustedProxies(p.app.Config)
|
||||
}
|
||||
byUser := func(prefix string) func(*http.Request) string {
|
||||
return func(r *http.Request) string {
|
||||
if u, ok := bouncer.User(r.Context()); ok && u.ID != 0 {
|
||||
return prefix + "|u:" + strconv.FormatUint(uint64(u.ID), 10)
|
||||
}
|
||||
return prefix + "|ip:" + surf.ClientIP(r, trusted)
|
||||
}
|
||||
}
|
||||
return map[string]surf.Bucket{
|
||||
bucketManage: {Max: 120, Decay: time.Minute, Key: byUser(bucketManage)},
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation declares the "Quizzes" admin menu with its side menu.
|
||||
func (p *Plugin) Navigation() []pact.NavigationItem {
|
||||
return []pact.NavigationItem{{
|
||||
|
||||
@@ -69,6 +69,8 @@ func TestRoutesRegistered(t *testing.T) {
|
||||
"POST /_quizzes/api/v1/attempts/{id}/answers",
|
||||
"POST /_quizzes/api/v1/attempts/{id}/finish",
|
||||
"POST /api/v1/quizzes/import",
|
||||
"GET /api/v1/quizzes",
|
||||
"GET /api/v1/quizzes/{slug}",
|
||||
}
|
||||
var got []string
|
||||
for _, r := range router.Routes() {
|
||||
@@ -84,6 +86,17 @@ func TestRoutesRegistered(t *testing.T) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if r.Pattern == "/api/v1/quizzes" || strings.HasPrefix(r.Pattern, "/api/v1/quizzes/") {
|
||||
for _, mw := range []string{"user.api_token", "throttle:golem15.quizzes.manage", "user.permission:golem15.quizzes.access_quizzes"} {
|
||||
if !slices.Contains(r.Middleware, mw) {
|
||||
t.Errorf("%s %s is not behind %s: %v", r.Method, r.Pattern, mw, r.Middleware)
|
||||
}
|
||||
}
|
||||
if !slices.Contains(r.Middleware, "user.scope:read") && !slices.Contains(r.Middleware, "user.scope:write") {
|
||||
t.Errorf("%s %s has no token scope: %v", r.Method, r.Pattern, r.Middleware)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(r.Middleware, "jwt.auth") {
|
||||
t.Errorf("%s %s is not behind jwt.auth: %v", r.Method, r.Pattern, r.Middleware)
|
||||
}
|
||||
|
||||
17
routes.go
17
routes.go
@@ -15,9 +15,15 @@ const ManagePrefix = "/api/v1/quizzes"
|
||||
// admin and through the management API.
|
||||
const permQuizzes = "golem15.quizzes.access_quizzes"
|
||||
|
||||
// bucketManage limits the owner manage routes (the MCP quiz tools). It runs
|
||||
// after user.api_token, so it keys by user.
|
||||
const bucketManage = "golem15.quizzes.manage"
|
||||
|
||||
// Routes mounts the quiz API under /_quizzes/api/v1 behind golem15.user's
|
||||
// jwt.auth middleware, and the management API under /api/v1/quizzes behind
|
||||
// its application-wide API token.
|
||||
// its application-wide API token: the import route, and the owner manage
|
||||
// routes the MCP quiz tools bind, which also need the access_quizzes
|
||||
// permission and run under the per-user golem15.quizzes.manage throttle.
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
h := api.New(p.app)
|
||||
r.Group("/_quizzes/api/v1", surf.Use("jwt.auth"), func(g pact.Router) {
|
||||
@@ -39,5 +45,14 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Group(ManagePrefix, surf.Use("throttle:60,1", "user.api_token"), func(g pact.Router) {
|
||||
g.Post("/import", h.ImportQuiz, "user.scope:write", "user.permission:"+permQuizzes)
|
||||
})
|
||||
// The owner manage routes (MCP). user.api_token runs first so the
|
||||
// bucket keys by user; every route needs the permission, so a student
|
||||
// whose groups lack it never lists or reaches these tools.
|
||||
perm := "user.permission:" + permQuizzes
|
||||
r.Group(ManagePrefix, surf.Use("user.api_token", "throttle:"+bucketManage), func(g pact.Router) {
|
||||
g.Get("", h.ManageListQuizzes, "user.scope:read", perm)
|
||||
g.Get("/{slug}", h.ManageShowQuiz, "user.scope:read", perm)
|
||||
g.Where("slug", `[a-z0-9-]+`)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user