feat: serve quizzes:import over HTTP behind the shared user API token
POST /api/v1/quizzes/import runs behind golem15.user's user.api_token with the write scope and the golem15.quizzes.access_quizzes permission.
This commit is contained in:
@@ -240,6 +240,8 @@ The navigation item is "Quizzes" ("Quizy" in Polish). The admin's relation manag
|
||||
|---------|---------|
|
||||
| `quizzes:import <file.json> [--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;
|
||||
|
||||
58
controllers/api/import.go
Normal file
58
controllers/api/import.go
Normal file
@@ -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,
|
||||
}})
|
||||
}
|
||||
86
import_api_postgres_test.go
Normal file
86
import_api_postgres_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
15
routes.go
15
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user