diff --git a/README.md b/README.md index 2e53da4..e1da6ad 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,8 @@ The navigation item is "Quizzes" ("Quizy" in Polish). The admin's relation manag |---------|---------| | `quizzes:import [--replace]` | Import one quiz from a JSON file. | +The same import is served over HTTP as `POST /api/v1/quizzes/import` (add `?replace=1` for `--replace`), with the import document as the body. The route runs behind the application-wide API token of `golem15.user` (`user.api_token`, created with `user:token:create`): the token needs the `write` scope and its user the `golem15.quizzes.access_quizzes` permission in one of their user groups. It answers `{"data": {"id", "slug", "created", "questions", "answers"}}`, 422 with a `message` for a document that does not parse, and 409 for an existing slug without `replace`. It needs a `golem15.user` that ships the shared API tokens. + The import runs in one transaction and is idempotent by slug: - a new slug creates the quiz; diff --git a/controllers/api/import.go b/controllers/api/import.go new file mode 100644 index 0000000..f0e7484 --- /dev/null +++ b/controllers/api/import.go @@ -0,0 +1,58 @@ +package api + +import ( + "errors" + "io" + "net/http" + + "git.golem15.com/golem15/sm-quizzes-plugin/classes" + "git.golem15.com/golem15/summercms/modules/wire" + "gorm.io/gorm" +) + +// ImportQuiz answers POST /import?replace=: quizzes:import over HTTP, for +// the management API behind golem15.user's API token. The body is the +// import document. A document that does not parse is a 422 and writes +// nothing; an existing slug is a 409 unless replace is 1 or true. +func (h *Handlers) ImportQuiz(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(r.Body) + var tooLarge *http.MaxBytesError + switch { + case errors.As(err, &tooLarge): + wire.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": true, "message": "Request body too large."}) + return + case err != nil: + wire.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": true, "message": "Unreadable request body."}) + return + } + doc, err := classes.ParseImport(raw) + if err != nil { + wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": err.Error()}) + return + } + var db *gorm.DB + if h.App != nil { + db, _ = h.App.Lookup[*gorm.DB]() + } + if db == nil { + wire.WriteOpaque500(w) + return + } + replace := r.URL.Query().Get("replace") + res, err := classes.Import(r.Context(), db, doc, replace == "1" || replace == "true") + if errors.Is(err, classes.ErrQuizExists) { + wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": true, "message": "A quiz with the slug " + doc.Slug + " already exists; pass replace=1 to replace its questions."}) + return + } + if err != nil { + wire.WriteOpaque500(w) + return + } + wire.WriteJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ + "id": res.Quiz.ID, + "slug": res.Quiz.Slug, + "created": res.Created, + "questions": res.Questions, + "answers": res.Answers, + }}) +} diff --git a/import_api_postgres_test.go b/import_api_postgres_test.go new file mode 100644 index 0000000..313b29e --- /dev/null +++ b/import_api_postgres_test.go @@ -0,0 +1,86 @@ +package quizzes_test + +import ( + "net/http" + "os" + "strings" + "testing" + + userclasses "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/summercms/modules/surf" + "gorm.io/gorm" +) + +// apiToken makes a shared golem15.user API token for a user. +func apiToken(t *testing.T, gdb *gorm.DB, userID uint, scopes ...string) string { + t.Helper() + secret, token, err := userclasses.MintAPIToken("usr_", userID, "test", scopes, nil, nil) + must(t, err) + must(t, gdb.Create(token).Error) + return secret +} + +func TestImportRoute(t *testing.T) { + app, plugins, gdb := migrated(t) + h, err := surf.Assemble(app, plugins) + must(t, err) + c := &client{t: t, h: h} + raw, err := os.ReadFile("testdata/quiz.json") + must(t, err) + doc := string(raw) + const path = "/api/v1/quizzes/import" + + editor, jwt := newUser(t, gdb, "ala@example.com") + student, _ := newUser(t, gdb, "ola@example.com") + var groupID uint + must(t, gdb.Raw(`INSERT INTO user_groups (name, code, permissions, created_at, updated_at) +VALUES ('Editors', 'editors', '{"golem15.quizzes.access_quizzes":1}', NOW(), NOW()) RETURNING id`).Scan(&groupID).Error) + must(t, gdb.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, editor.ID, groupID).Error) + + // No token, a session JWT, a read-only token and a user without the + // permission are all refused. + for name, tc := range map[string]struct { + token string + want int + }{ + "no token": {"", http.StatusUnauthorized}, + "session JWT": {jwt, http.StatusUnauthorized}, + "read-only token": {apiToken(t, gdb, editor.ID, "read"), http.StatusForbidden}, + "without permission": {apiToken(t, gdb, student.ID, "read", "write"), http.StatusForbidden}, + } { + if code, _ := c.do("POST", path, tc.token, doc); code != tc.want { + t.Errorf("%s: POST %s = %d, want %d", name, path, code, tc.want) + } + } + if n := count(t, gdb); n != 0 { + t.Fatalf("quizzes after refused imports = %d", n) + } + + token := apiToken(t, gdb, editor.ID, "read", "write") + code, body := c.do("POST", path, token, doc) + res := data(body) + if code != http.StatusOK || res["slug"] != "bezpieczenstwo-pojecia" || res["created"] != true || res["questions"] != float64(3) || res["answers"] != float64(5) { + t.Fatalf("import = %d %v", code, body) + } + if code, _ := c.do("POST", path, token, doc); code != http.StatusConflict { + t.Errorf("second import without replace = %d, want 409", code) + } + code, body = c.do("POST", path+"?replace=1", token, doc) + if code != http.StatusOK || data(body)["created"] != false { + t.Errorf("import with replace = %d %v", code, body) + } + code, body = c.do("POST", path, token, `{"title":"X","questions":[]}`) + if code != http.StatusUnprocessableEntity || !strings.Contains(body["message"].(string), "questions must not be empty") { + t.Errorf("invalid document = %d %v", code, body) + } + if n := count(t, gdb); n != 1 { + t.Errorf("quizzes = %d, want 1", n) + } +} + +func count(t *testing.T, gdb *gorm.DB) int64 { + t.Helper() + var n int64 + must(t, gdb.Table("golem15_quizzes_quizzes").Count(&n).Error) + return n +} diff --git a/plugin_test.go b/plugin_test.go index b72d903..b3eb1eb 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -64,6 +64,7 @@ func TestRoutesRegistered(t *testing.T) { "GET /_quizzes/api/v1/attempts/{id}", "POST /_quizzes/api/v1/attempts/{id}/answers", "POST /_quizzes/api/v1/attempts/{id}/finish", + "POST /api/v1/quizzes/import", } var got []string for _, r := range router.Routes() { @@ -71,6 +72,14 @@ func TestRoutesRegistered(t *testing.T) { continue } got = append(got, r.Method+" "+r.Pattern) + if r.Pattern == "/api/v1/quizzes/import" { + for _, mw := range []string{"user.api_token", "user.scope:write", "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) + } + } + continue + } if !slices.Contains(r.Middleware, "jwt.auth") { t.Errorf("%s %s is not behind jwt.auth: %v", r.Method, r.Pattern, r.Middleware) } diff --git a/routes.go b/routes.go index cbe9afa..a235aea 100644 --- a/routes.go +++ b/routes.go @@ -8,8 +8,16 @@ import ( var _ pact.HasRoutes = (*Plugin)(nil) +// ManagePrefix is the path prefix of the management API. +const ManagePrefix = "/api/v1/quizzes" + +// permQuizzes is the permission that lets a user manage quizzes, in the +// admin and through the management API. +const permQuizzes = "golem15.quizzes.access_quizzes" + // Routes mounts the quiz API under /_quizzes/api/v1 behind golem15.user's -// jwt.auth middleware. +// jwt.auth middleware, and the management API under /api/v1/quizzes behind +// its application-wide API token. 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) { @@ -26,5 +34,10 @@ func (p *Plugin) Routes(r pact.Router) error { g.Post("/attempts/{id}/finish", h.Finish) g.Where("id", `[0-9]+`) }) + // quizzes:import over HTTP. The token's scope limits delegation; + // user.permission checks the owner's current group permissions. + 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) + }) return nil }