- TestUploadTicket: HMAC under the jz.bm.upload.v1: label, expiry, malformed and foreign tickets, single use through the cache - TestSignedMaterialUploadRefusesBadTickets, TestCreateMaterialUploadNeedsCaller - TestRoutes, TestBMMCP and TestManageBucketKey expect the upload-url route, the uploads route with its two buckets and the upload tool - compile-only stubs
669 lines
23 KiB
Go
669 lines
23 KiB
Go
package bm_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/pier"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
bm "git.golem15.com/jakub/sm-bm-plugin"
|
|
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
|
|
"git.golem15.com/jakub/sm-bm-plugin/models"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// pinnedTools is the sorted tool list of golem15.user plus jz.bm.
|
|
var pinnedTools = []string{
|
|
"bm_course",
|
|
"bm_courses",
|
|
"bm_dashboard",
|
|
"bm_deadlines",
|
|
"bm_manage_attach_material_file",
|
|
"bm_manage_create_class_session",
|
|
"bm_manage_create_course",
|
|
"bm_manage_create_deadline",
|
|
"bm_manage_create_material_upload",
|
|
"bm_manage_create_meeting",
|
|
"bm_manage_create_reading",
|
|
"bm_manage_delete_class_session",
|
|
"bm_manage_delete_deadline",
|
|
"bm_manage_delete_material",
|
|
"bm_manage_delete_reading",
|
|
"bm_manage_detach_material_file",
|
|
"bm_manage_list_class_sessions",
|
|
"bm_manage_list_courses",
|
|
"bm_manage_list_deadlines",
|
|
"bm_manage_list_materials",
|
|
"bm_manage_list_meetings",
|
|
"bm_manage_list_readings",
|
|
"bm_manage_save_material",
|
|
"bm_manage_show_class_session",
|
|
"bm_manage_show_course",
|
|
"bm_manage_show_deadline",
|
|
"bm_manage_show_material",
|
|
"bm_manage_show_meeting",
|
|
"bm_manage_show_reading",
|
|
"bm_manage_update_class_session",
|
|
"bm_manage_update_course",
|
|
"bm_manage_update_deadline",
|
|
"bm_manage_update_material",
|
|
"bm_manage_update_meeting",
|
|
"bm_manage_update_reading",
|
|
"bm_mark_reading_owned",
|
|
"bm_mark_reading_read",
|
|
"bm_material",
|
|
"bm_materials",
|
|
"bm_meetings",
|
|
"bm_readings",
|
|
"bm_schedule",
|
|
"bm_unmark_reading_owned",
|
|
"bm_unmark_reading_read",
|
|
"user_me",
|
|
}
|
|
|
|
// queryKeys are the query keys each handler reads, by tool name: the
|
|
// student handlers' query(r, ...) and the management record Filters
|
|
// (routes.go) plus include_body of the material views. A tool query field
|
|
// outside these would be sent and silently ignored.
|
|
var queryKeys = map[string][]string{
|
|
"manage_list_courses": {"semester", "form", "code"},
|
|
"manage_list_class_sessions": {"course_id", "meeting_id"},
|
|
"manage_list_readings": {"course_id", "meeting_id", "is_required"},
|
|
"manage_list_deadlines": {"course_id", "kind"},
|
|
"manage_list_materials": {"include_body"},
|
|
"manage_show_material": {"include_body"},
|
|
"courses": {"semester"},
|
|
"schedule": {"from", "to"},
|
|
"deadlines": {"from", "to"},
|
|
"readings": {"course"},
|
|
"materials": {"course", "meeting", "tag", "type", "page", "per_page"},
|
|
"material": {"include_body"},
|
|
}
|
|
|
|
// bmTools returns jz.bm's declared tools.
|
|
func bmTools() []pact.MCPTool {
|
|
return (&bm.Plugin{}).MCP().Tools
|
|
}
|
|
|
|
// TestBMMCP (criterion 4, D-09, D-14, D-15) mounts pier against golem15.user
|
|
// and jz.bm without a database and pins the compiled tools; every tool binds
|
|
// a token-guarded jz.bm route, names its scope, and reads only keys the
|
|
// route reads. Runs under -short.
|
|
func TestBMMCP(t *testing.T) {
|
|
tools := bmTools()
|
|
|
|
t.Run("compiles", func(t *testing.T) {
|
|
app := backpack.New(testConfig(t))
|
|
if err := app.Config.Set("app.url", "https://bm.example.test"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plugins := activate(t, app)
|
|
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("routes", func(t *testing.T) {
|
|
for _, tool := range tools {
|
|
if !strings.HasPrefix(tool.Path, bm.TokenPrefix+"/") && !strings.HasPrefix(tool.Path, bm.ManagePrefix+"/") {
|
|
t.Errorf("%s binds %s, outside %s and %s", tool.Name, tool.Path, bm.TokenPrefix, bm.ManagePrefix)
|
|
}
|
|
if tool.Guard != "user" {
|
|
t.Errorf("%s: Guard %q, want user", tool.Name, tool.Guard)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("descriptions", func(t *testing.T) {
|
|
for _, tool := range tools {
|
|
if !strings.Contains(tool.Description, "Needs scope read") && !strings.Contains(tool.Description, "Needs scope write") {
|
|
t.Errorf("%s: description does not name its scope: %q", tool.Name, tool.Description)
|
|
}
|
|
if tool.Title == "" {
|
|
t.Errorf("%s has no title", tool.Name)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("inputs", func(t *testing.T) {
|
|
for _, tool := range tools {
|
|
rt := reflect.TypeOf(tool.Input)
|
|
for rt.Kind() == reflect.Pointer {
|
|
rt = rt.Elem()
|
|
}
|
|
if rt.Kind() != reflect.Struct {
|
|
t.Errorf("%s: input is %s, not a struct", tool.Name, rt.Kind())
|
|
continue
|
|
}
|
|
for i := 0; i < rt.NumField(); i++ {
|
|
f := rt.Field(i)
|
|
key, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
|
if key == "dry_run" {
|
|
t.Errorf("%s has a dry_run field (D-14)", tool.Name)
|
|
}
|
|
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") != "":
|
|
if !slices.Contains(queryKeys[tool.Name], f.Tag.Get("query")) {
|
|
t.Errorf("%s.%s: query key %q is not one the handler reads", tool.Name, f.Name, 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 %q", tool.Name, f.Name, tool.Method, key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("management", func(t *testing.T) {
|
|
testManageTools(t, tools)
|
|
})
|
|
|
|
}
|
|
|
|
// recordModel is what the management record tests read of a model.
|
|
type recordModel interface {
|
|
Fillable() []string
|
|
Rules() map[string]string
|
|
}
|
|
|
|
// manageTables maps a management path segment to its model (nil for
|
|
// materials), its permission code and whether the route can delete.
|
|
var manageTables = map[string]struct {
|
|
model recordModel
|
|
perm string
|
|
deletable bool
|
|
}{
|
|
"courses": {models.Course{}, "jz.bm.access_courses", false},
|
|
"meetings": {models.Meeting{}, "jz.bm.access_schedule", false},
|
|
"class-sessions": {models.ClassSession{}, "jz.bm.access_schedule", true},
|
|
"readings": {models.Reading{}, "jz.bm.access_courses", true},
|
|
"deadlines": {models.Deadline{}, "jz.bm.access_schedule", true},
|
|
"materials": {nil, "jz.bm.access_materials", true},
|
|
}
|
|
|
|
// materialBodyKeys are the keys POST and PUT /materials read (materialInput).
|
|
var materialBodyKeys = []string{"course_id", "meeting_id", "title", "type", "body", "source", "url", "published_at", "tags"}
|
|
|
|
// bodyFields returns the JSON keys of a tool input's body fields and
|
|
// whether each is optional (omitempty).
|
|
func bodyFields(input any) map[string]bool {
|
|
rt := reflect.TypeOf(input)
|
|
for rt.Kind() == reflect.Pointer {
|
|
rt = rt.Elem()
|
|
}
|
|
out := map[string]bool{}
|
|
for i := 0; i < rt.NumField(); i++ {
|
|
f := rt.Field(i)
|
|
if f.Tag.Get("path") != "" || f.Tag.Get("query") != "" {
|
|
continue
|
|
}
|
|
key, opts, _ := strings.Cut(f.Tag.Get("json"), ",")
|
|
out[key] = strings.Contains(opts, "omitempty")
|
|
}
|
|
return out
|
|
}
|
|
|
|
// requiredColumns are the columns whose rules start with or contain
|
|
// required.
|
|
func requiredColumns(m recordModel) []string {
|
|
var out []string
|
|
for col, rule := range m.Rules() {
|
|
if slices.Contains(strings.Split(rule, "|"), "required") {
|
|
out = append(out, col)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// testManageTools (D-13, D-14, D-15, D-22) checks the management tools
|
|
// against the routes and the models.
|
|
func testManageTools(t *testing.T, tools []pact.MCPTool) {
|
|
t.Helper()
|
|
deletes := map[string]bool{}
|
|
manage := 0
|
|
for _, tool := range tools {
|
|
if !strings.HasPrefix(tool.Name, "manage_") {
|
|
if !strings.HasPrefix(tool.Path, bm.TokenPrefix+"/") {
|
|
t.Errorf("%s binds %s, outside the token twins", tool.Name, tool.Path)
|
|
}
|
|
continue
|
|
}
|
|
manage++
|
|
if !strings.HasPrefix(tool.Path, bm.ManagePrefix+"/") {
|
|
t.Errorf("%s binds %s, outside %s", tool.Name, tool.Path, bm.ManagePrefix)
|
|
continue
|
|
}
|
|
rest := strings.TrimPrefix(tool.Path, bm.ManagePrefix+"/")
|
|
segment, _, _ := strings.Cut(rest, "/")
|
|
table, ok := manageTables[segment]
|
|
if !ok {
|
|
t.Errorf("%s binds %s, not a managed table (seed and me are not tools)", tool.Name, tool.Path)
|
|
continue
|
|
}
|
|
if !strings.Contains(tool.Description, "Needs scope") || !strings.Contains(tool.Description, "jz.bm.access_") {
|
|
t.Errorf("%s: description names no scope or permission: %q", tool.Name, tool.Description)
|
|
}
|
|
if !strings.Contains(tool.Description, table.perm) {
|
|
t.Errorf("%s: description does not name %s", tool.Name, table.perm)
|
|
}
|
|
if tool.Method == http.MethodDelete {
|
|
deletes[segment] = true
|
|
if !table.deletable {
|
|
t.Errorf("%s deletes %s, which the route cannot", tool.Name, segment)
|
|
}
|
|
if !strings.Contains(tool.Description, "cannot be undone") {
|
|
t.Errorf("%s: description does not say the delete cannot be undone", tool.Name)
|
|
}
|
|
}
|
|
fields := bodyFields(tool.Input)
|
|
if segment == "materials" && strings.Contains(rest, "/files") {
|
|
testFileTool(t, tool, fields)
|
|
if tool.Method == http.MethodDelete {
|
|
deletes[segment] = true
|
|
}
|
|
continue
|
|
}
|
|
if segment == "materials" {
|
|
if !strings.Contains(tool.Description, bm.MCPMaterialPrivacy) {
|
|
t.Errorf("%s: description lacks the material privacy sentence", tool.Name)
|
|
}
|
|
for key := range fields {
|
|
if !slices.Contains(materialBodyKeys, key) {
|
|
t.Errorf("%s: body key %q is not a material key", tool.Name, key)
|
|
}
|
|
}
|
|
if tool.Method == http.MethodPost || tool.Method == http.MethodPut {
|
|
for _, key := range []string{"title", "type", "body", "source", "url"} {
|
|
if optional, ok := fields[key]; !ok || optional {
|
|
t.Errorf("%s: %s must be a required field (present %v)", tool.Name, key, ok)
|
|
}
|
|
}
|
|
if !strings.Contains(tool.Description, "replaced as sent") {
|
|
t.Errorf("%s: description does not say body, source and url are replaced as sent", tool.Name)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
fillable := table.model.Fillable()
|
|
for key := range fields {
|
|
if !slices.Contains(fillable, key) {
|
|
t.Errorf("%s: body key %q is not a Fillable column of %s", tool.Name, key, segment)
|
|
}
|
|
}
|
|
switch tool.Method {
|
|
case http.MethodPost:
|
|
for _, col := range fillable {
|
|
if _, ok := fields[col]; !ok {
|
|
t.Errorf("%s: Fillable column %s has no field", tool.Name, col)
|
|
}
|
|
}
|
|
for _, col := range requiredColumns(table.model) {
|
|
if optional, ok := fields[col]; !ok || optional {
|
|
t.Errorf("%s: required column %s is not a required field", tool.Name, col)
|
|
}
|
|
}
|
|
case http.MethodPatch:
|
|
for _, col := range fillable {
|
|
if optional, ok := fields[col]; !ok || !optional {
|
|
t.Errorf("%s: column %s must be an optional field (present %v)", tool.Name, col, ok)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if manage != manageToolCount {
|
|
t.Errorf("%d management tools, want %d", manage, manageToolCount)
|
|
}
|
|
want := map[string]bool{"class-sessions": true, "readings": true, "deadlines": true, "materials": true}
|
|
if !reflect.DeepEqual(deletes, want) {
|
|
t.Errorf("delete tools for %v, want %v", deletes, want)
|
|
}
|
|
}
|
|
|
|
// manageToolCount is the number of management tools: 28 record and
|
|
// material tools plus the material file tools.
|
|
const manageToolCount = 31
|
|
|
|
// fileBodyKeys are the body keys of each material file tool, exactly.
|
|
var fileBodyKeys = map[string]map[string]bool{
|
|
"manage_attach_material_file": {"name": false, "mime": true, "content_base64": false},
|
|
"manage_detach_material_file": {},
|
|
"manage_create_material_upload": {},
|
|
}
|
|
|
|
// testFileTool (D-10, D-11, D-17) checks one material file tool: its body
|
|
// keys, the public-file warning and the privacy sentence, and that the
|
|
// detach tool is destructive.
|
|
func testFileTool(t *testing.T, tool pact.MCPTool, fields map[string]bool) {
|
|
t.Helper()
|
|
want, ok := fileBodyKeys[tool.Name]
|
|
if !ok {
|
|
t.Errorf("%s: not a known material file tool", tool.Name)
|
|
return
|
|
}
|
|
if !reflect.DeepEqual(fields, want) {
|
|
t.Errorf("%s: body keys (optional) %v, want %v", tool.Name, fields, want)
|
|
}
|
|
if !strings.Contains(tool.Description, api.MaterialFileWarning) {
|
|
t.Errorf("%s: description lacks the public-file warning", tool.Name)
|
|
}
|
|
if !strings.Contains(tool.Description, bm.MCPMaterialPrivacy) {
|
|
t.Errorf("%s: description lacks the material privacy sentence", tool.Name)
|
|
}
|
|
if !strings.Contains(tool.Description, "Needs scope write and the jz.bm.access_materials permission") {
|
|
t.Errorf("%s: description does not name scope write and jz.bm.access_materials", tool.Name)
|
|
}
|
|
rt := reflect.TypeOf(tool.Input)
|
|
idPath := false
|
|
for i := 0; i < rt.NumField(); i++ {
|
|
if rt.Field(i).Tag.Get("path") == "id" {
|
|
idPath = true
|
|
}
|
|
}
|
|
if !idPath {
|
|
t.Errorf("%s: no id path field", tool.Name)
|
|
}
|
|
if tool.Name == "manage_create_material_upload" {
|
|
for _, s := range []string{"10 minutes", "curl", "50 MB", "once"} {
|
|
if !strings.Contains(tool.Description, s) {
|
|
t.Errorf("%s: description does not mention %q", tool.Name, s)
|
|
}
|
|
}
|
|
}
|
|
if tool.Name == "manage_detach_material_file" {
|
|
if tool.Method != http.MethodDelete || (tool.Destructive != nil && !*tool.Destructive) {
|
|
t.Errorf("%s is not destructive (method %s)", tool.Name, tool.Method)
|
|
}
|
|
} else if tool.Method == http.MethodDelete || (tool.Destructive != nil && *tool.Destructive) {
|
|
t.Errorf("%s is destructive", tool.Name)
|
|
}
|
|
}
|
|
|
|
// studentRoutes are the routes mountStudent registers, in order.
|
|
var studentRoutes = []string{
|
|
"GET /dashboard",
|
|
"GET /courses",
|
|
"GET /courses/{id}",
|
|
"GET /schedule",
|
|
"GET /meetings",
|
|
"GET /deadlines",
|
|
"GET /readings",
|
|
"PUT /readings/{id}/progress",
|
|
"DELETE /readings/{id}/progress",
|
|
"PUT /readings/{id}/owned",
|
|
"DELETE /readings/{id}/owned",
|
|
"GET /materials",
|
|
"GET /materials/{id}",
|
|
}
|
|
|
|
// TestTokenMirrorRoutes is the invariant of the two student surfaces: every
|
|
// student route exists under APIPrefix (jwt.auth, no user.* middleware) and
|
|
// under TokenPrefix (user.api_token, the token bucket and the matching
|
|
// scope, no jwt.auth); the calendar feed and the chat have no twin.
|
|
func TestTokenMirrorRoutes(t *testing.T) {
|
|
app := backpack.New(testConfig(t))
|
|
plugins := activate(t, app)
|
|
router, err := surf.BuildRouter(app, plugins)
|
|
if err != nil {
|
|
t.Fatalf("BuildRouter: %v", err)
|
|
}
|
|
byRoute := map[string][]string{}
|
|
for _, rt := range router.Routes() {
|
|
if rt.PluginID != "jz.bm" {
|
|
continue
|
|
}
|
|
byRoute[rt.Method+" "+rt.Pattern] = rt.Middleware
|
|
if strings.HasPrefix(rt.Pattern, bm.TokenPrefix+"/") {
|
|
rest := strings.TrimPrefix(rt.Pattern, bm.TokenPrefix)
|
|
if !slices.Contains(studentRoutes, rt.Method+" "+rest) {
|
|
t.Errorf("%s %s under the token prefix is not a student route", rt.Method, rt.Pattern)
|
|
}
|
|
}
|
|
}
|
|
for _, route := range studentRoutes {
|
|
method, pattern, _ := strings.Cut(route, " ")
|
|
jwt, ok := byRoute[method+" "+bm.APIPrefix+pattern]
|
|
if !ok {
|
|
t.Errorf("%s %s is not registered", method, bm.APIPrefix+pattern)
|
|
} else {
|
|
if !slices.Contains(jwt, "jwt.auth") {
|
|
t.Errorf("%s %s lacks jwt.auth: %v", method, bm.APIPrefix+pattern, jwt)
|
|
}
|
|
if slices.ContainsFunc(jwt, func(name string) bool { return strings.HasPrefix(name, "user.") }) {
|
|
t.Errorf("%s %s carries user.* middleware: %v", method, bm.APIPrefix+pattern, jwt)
|
|
}
|
|
}
|
|
tok, ok := byRoute[method+" "+bm.TokenPrefix+pattern]
|
|
if !ok {
|
|
t.Errorf("%s %s (the token twin) is not registered", method, bm.TokenPrefix+pattern)
|
|
continue
|
|
}
|
|
scope := "user.scope:write"
|
|
if method == http.MethodGet {
|
|
scope = "user.scope:read"
|
|
}
|
|
for _, name := range []string{"user.api_token", "throttle:jz.bm.token", scope} {
|
|
if !slices.Contains(tok, name) {
|
|
t.Errorf("%s %s lacks %s: %v", method, bm.TokenPrefix+pattern, name, tok)
|
|
}
|
|
}
|
|
if slices.Contains(tok, "jwt.auth") {
|
|
t.Errorf("%s %s carries jwt.auth: %v", method, bm.TokenPrefix+pattern, tok)
|
|
}
|
|
if slices.Index(tok, "user.api_token") > slices.Index(tok, "throttle:jz.bm.token") {
|
|
t.Errorf("%s %s: the token bucket runs before the guard: %v", method, bm.TokenPrefix+pattern, tok)
|
|
}
|
|
}
|
|
for route := range byRoute {
|
|
if strings.Contains(route, bm.TokenPrefix+"/calendar-feed") || strings.Contains(route, bm.TokenPrefix+"/chat") {
|
|
t.Errorf("%s has a token twin", route)
|
|
}
|
|
}
|
|
}
|
|
|
|
// dispatched returns r as pier dispatches it in-process: the api_token
|
|
// marker with principal id on its context.
|
|
func dispatched(r *http.Request, id uint) *http.Request {
|
|
return r.WithContext(bouncer.WithDispatch(r.Context(), &bouncer.Dispatch{
|
|
Guards: []string{"api_token"},
|
|
Principal: &bouncer.Principal{ID: id},
|
|
}))
|
|
}
|
|
|
|
// TestMCPLinks (D-16): under MCP every string under url or *_url that starts
|
|
// with a single / becomes absolute; other keys, protocol-relative and
|
|
// absolute URLs, error answers and plain HTTP answers are left alone.
|
|
func TestMCPLinks(t *testing.T) {
|
|
const answer = `{"data":{"url":"/storage/x","files":[{"url":"/storage/y","name":"/n"}],"syllabus_url":"/sylabusy/z.pdf","link":"https://x.test/a","other":"/keep","cdn_url":"//cdn.test/a","abs_url":"https://y.test/b","count":12345678901234567890,"nested":{"items":[{"thumb_url":"/t.png"}]}}}`
|
|
mw := (&bm.Plugin{}).Middlewares()[bm.MiddlewareMCPLinks]
|
|
if mw == nil {
|
|
t.Fatalf("%s is not exported", bm.MiddlewareMCPLinks)
|
|
}
|
|
handler := func(status int, body string) http.Handler {
|
|
return mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
}
|
|
|
|
t.Run("plain HTTP untouched", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
handler(http.StatusOK, answer).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
if rec.Body.String() != answer {
|
|
t.Errorf("plain answer = %s, want it unchanged", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("MCP answer absolute", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
handler(http.StatusOK, answer).ServeHTTP(rec, dispatched(httptest.NewRequest(http.MethodGet, "/", nil), 7))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
if cl := rec.Header().Get("Content-Length"); cl != strconv.Itoa(rec.Body.Len()) {
|
|
t.Errorf("Content-Length = %s, body is %d bytes", cl, rec.Body.Len())
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
|
|
dec.UseNumber()
|
|
var got struct {
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
if err := dec.Decode(&got); err != nil {
|
|
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
|
}
|
|
d := got.Data
|
|
want := map[string]string{
|
|
"url": "http://example.com/storage/x",
|
|
"syllabus_url": "http://example.com/sylabusy/z.pdf",
|
|
"link": "https://x.test/a",
|
|
"other": "/keep",
|
|
"cdn_url": "//cdn.test/a",
|
|
"abs_url": "https://y.test/b",
|
|
}
|
|
for k, v := range want {
|
|
if d[k] != v {
|
|
t.Errorf("%s = %v, want %s", k, d[k], v)
|
|
}
|
|
}
|
|
file := d["files"].([]any)[0].(map[string]any)
|
|
if file["url"] != "http://example.com/storage/y" || file["name"] != "/n" {
|
|
t.Errorf("files[0] = %v", file)
|
|
}
|
|
thumb := d["nested"].(map[string]any)["items"].([]any)[0].(map[string]any)
|
|
if thumb["thumb_url"] != "http://example.com/t.png" {
|
|
t.Errorf("nested thumb_url = %v", thumb["thumb_url"])
|
|
}
|
|
if n, _ := d["count"].(json.Number); n.String() != "12345678901234567890" {
|
|
t.Errorf("count = %v, want the number kept exactly", d["count"])
|
|
}
|
|
})
|
|
|
|
t.Run("MCP forwarded scheme", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Host = "bm.example.test"
|
|
r.Header.Set("X-Forwarded-Proto", "https")
|
|
handler(http.StatusOK, `{"url":"/a"}`).ServeHTTP(rec, dispatched(r, 7))
|
|
if !strings.Contains(rec.Body.String(), `"https://bm.example.test/a"`) {
|
|
t.Errorf("answer = %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("MCP error untouched", func(t *testing.T) {
|
|
const notFound = `{"error":"not_found","url":"/x"}`
|
|
rec := httptest.NewRecorder()
|
|
handler(http.StatusNotFound, notFound).ServeHTTP(rec, dispatched(httptest.NewRequest(http.MethodGet, "/", nil), 7))
|
|
if rec.Code != http.StatusNotFound || rec.Body.String() != notFound {
|
|
t.Errorf("error answer = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestManageBucketKey (research Pitfall 6): a dispatched management call is
|
|
// throttled per user, a bearer request per token hash, a bare one per IP;
|
|
// the token twins are throttled per user.
|
|
func TestManageBucketKey(t *testing.T) {
|
|
buckets := (&bm.Plugin{}).Buckets()
|
|
manage, ok := buckets["jz.bm.manage"]
|
|
if !ok {
|
|
t.Fatal("jz.bm.manage is not declared")
|
|
}
|
|
if manage.Max != 120 {
|
|
t.Errorf("jz.bm.manage Max = %d, want 120", manage.Max)
|
|
}
|
|
if got := manage.Key(dispatched(httptest.NewRequest(http.MethodGet, "/", nil), 7)); got != "jz.bm.manage.user|7" {
|
|
t.Errorf("dispatched key = %q, want jz.bm.manage.user|7", got)
|
|
}
|
|
bearer := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
bearer.Header.Set("Authorization", "Bearer usr_secret")
|
|
if got := manage.Key(bearer); !strings.HasPrefix(got, "jz.bm.manage.token|") || strings.Contains(got, "usr_secret") {
|
|
t.Errorf("bearer key = %q, want the token hash", got)
|
|
}
|
|
if got := manage.Key(httptest.NewRequest(http.MethodGet, "/", nil)); !strings.HasPrefix(got, "jz.bm.manage.ip|") {
|
|
t.Errorf("bare key = %q, want the IP", got)
|
|
}
|
|
ip, ok := buckets["jz.bm.upload-ip"]
|
|
if !ok || ip.Max != 10 {
|
|
t.Errorf("jz.bm.upload-ip declared %v with Max %d, want 10", ok, ip.Max)
|
|
} else if got := ip.Key(httptest.NewRequest(http.MethodPost, "/", nil)); !strings.HasPrefix(got, "jz.bm.upload.ip|") {
|
|
t.Errorf("upload IP key = %q", got)
|
|
}
|
|
ticket, ok := buckets["jz.bm.upload-ticket"]
|
|
if !ok || ticket.Max != 3 {
|
|
t.Errorf("jz.bm.upload-ticket declared %v with Max %d, want 3", ok, ticket.Max)
|
|
} else {
|
|
r := httptest.NewRequest(http.MethodPost, "/", nil)
|
|
r.SetPathValue("ticket", "secret-ticket.mac")
|
|
sum := sha256.Sum256([]byte("secret-ticket.mac"))
|
|
if got, want := ticket.Key(r), "jz.bm.upload.ticket|"+hex.EncodeToString(sum[:])[:32]; got != want {
|
|
t.Errorf("upload ticket key = %q, want %q", got, want)
|
|
}
|
|
}
|
|
token, ok := buckets["jz.bm.token"]
|
|
if !ok {
|
|
t.Fatal("jz.bm.token is not declared")
|
|
}
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r = r.WithContext(bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 7}))
|
|
if got := token.Key(r); got != "jz.bm.token|u:7" {
|
|
t.Errorf("token twin key = %q, want jz.bm.token|u:7", got)
|
|
}
|
|
}
|