- 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
185 lines
5.8 KiB
Go
185 lines
5.8 KiB
Go
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)
|
|
}
|