The management API could only upsert a material on its title and course,
so a renamed note became a second material. PUT /materials/{id} applies the
same body to one material (422 when the new title and course belong to
another one), GET /materials/{id} shows a draft or published material with
its public files, and the upsert, update and validation share one path.
The README documents the management API.
206 lines
7.0 KiB
Go
206 lines
7.0 KiB
Go
package bm_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
)
|
|
|
|
func TestManagementAPIUsesSharedTokenAndCurrentUserPermissions(t *testing.T) {
|
|
app, plugins, db := migrated(t)
|
|
|
|
var userID uint
|
|
if err := db.Raw(`
|
|
INSERT INTO users (email, password, must_change_password)
|
|
VALUES ('bm-shared-token@example.test', '', false)
|
|
RETURNING id`).Scan(&userID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var groupID uint
|
|
if err := db.Raw(`
|
|
INSERT INTO user_groups (name, code, permissions, created_at, updated_at)
|
|
VALUES ('BM API', 'bm-api', '{"jz.bm.access_courses":1}', NOW(), NOW())
|
|
RETURNING id`).Scan(&groupID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, userID, groupID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secret, token, err := userclasses.MintAPIToken("usr_", userID, "BM integration", []string{"read"}, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(token).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
handler, err := surf.Assemble(app, plugins)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := func(path string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("Authorization", "Bearer "+secret)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
if rec := request("/api/v1/bm/courses"); rec.Code != http.StatusOK {
|
|
t.Fatalf("permitted courses request = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := request("/api/v1/bm/materials"); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("ungranted materials request = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if err := db.Model(&struct{}{}).Table("user_groups").Where("id = ?", groupID).Update("permissions", `{}`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec := request("/api/v1/bm/courses"); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Forbidden") {
|
|
t.Fatalf("request after permission revocation = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestManagementMaterialsUpsertAndUpdate drives the material routes of the
|
|
// management API with a write token: the upsert identity (title and course),
|
|
// the update by id with a rename and a draft, the identity conflict, and
|
|
// the 404s.
|
|
func TestManagementMaterialsUpsertAndUpdate(t *testing.T) {
|
|
app, plugins, db := migrated(t)
|
|
|
|
var userID uint
|
|
if err := db.Raw(`
|
|
INSERT INTO users (email, password, must_change_password)
|
|
VALUES ('bm-materials-token@example.test', '', false)
|
|
RETURNING id`).Scan(&userID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var groupID uint
|
|
if err := db.Raw(`
|
|
INSERT INTO user_groups (name, code, permissions, created_at, updated_at)
|
|
VALUES ('BM materials', 'bm-materials', '{"jz.bm.access_materials":1}', NOW(), NOW())
|
|
RETURNING id`).Scan(&groupID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, userID, groupID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secret, token, err := userclasses.MintAPIToken("usr_", userID, "BM notes", []string{"read", "write"}, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(token).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
handler, err := surf.Assemble(app, plugins)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
send := func(method, path string, payload any) (*httptest.ResponseRecorder, map[string]any) {
|
|
t.Helper()
|
|
var rd io.Reader
|
|
if payload != nil {
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rd = bytes.NewReader(raw)
|
|
}
|
|
req := httptest.NewRequest(method, path, rd)
|
|
req.Header.Set("Authorization", "Bearer "+secret)
|
|
if payload != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
var out map[string]any
|
|
if rec.Body.Len() > 0 {
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
|
}
|
|
return rec, out
|
|
}
|
|
data := func(out map[string]any) map[string]any {
|
|
t.Helper()
|
|
d, _ := out["data"].(map[string]any)
|
|
if d == nil {
|
|
t.Fatalf("no data object in %v", out)
|
|
}
|
|
return d
|
|
}
|
|
idPath := func(id any) string { return "/api/v1/bm/materials/" + strconv.Itoa(int(id.(float64))) }
|
|
|
|
note := map[string]any{"title": "Definicje globalizacji", "type": "notatka", "body": "# Robertson\n\n> single place", "tags": []string{"Globalizacja", "Zjazd 2"}}
|
|
rec, out := send(http.MethodPost, "/api/v1/bm/materials", note)
|
|
if rec.Code != http.StatusCreated || out["created"] != true {
|
|
t.Fatalf("create = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
first := data(out)
|
|
if first["published_at"] == nil {
|
|
t.Error("a new material without published_at is not published")
|
|
}
|
|
if tags, _ := first["tags"].([]any); len(tags) != 2 || tags[1].(map[string]any)["slug"] != "zjazd-2" {
|
|
t.Errorf("tags = %v", first["tags"])
|
|
}
|
|
|
|
rec, out = send(http.MethodPost, "/api/v1/bm/materials", note)
|
|
if rec.Code != http.StatusOK || out["created"] != false || data(out)["id"] != first["id"] {
|
|
t.Fatalf("repeated upsert = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
rec, out = send(http.MethodGet, idPath(first["id"]), nil)
|
|
if rec.Code != http.StatusOK || data(out)["title"] != "Definicje globalizacji" {
|
|
t.Fatalf("show = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if files, ok := data(out)["files"].([]any); !ok || len(files) != 0 {
|
|
t.Errorf("files = %v", data(out)["files"])
|
|
}
|
|
|
|
renamed := map[string]any{"title": "Definicje globalizacji (zjazd 2)", "type": "notatka", "body": "zmienione", "published_at": nil}
|
|
rec, out = send(http.MethodPut, idPath(first["id"]), renamed)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("update = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
updated := data(out)
|
|
if updated["title"] != renamed["title"] || updated["body"] != "zmienione" || updated["published_at"] != nil {
|
|
t.Errorf("updated = %v", updated)
|
|
}
|
|
if tags, _ := updated["tags"].([]any); len(tags) != 2 {
|
|
t.Errorf("an update without tags changed them: %v", updated["tags"])
|
|
}
|
|
|
|
rec, out = send(http.MethodPost, "/api/v1/bm/materials", note)
|
|
if rec.Code != http.StatusCreated || data(out)["id"] == first["id"] {
|
|
t.Fatalf("upsert after the rename = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
second := data(out)
|
|
|
|
rec, out = send(http.MethodPut, idPath(second["id"]), renamed)
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"title"`) {
|
|
t.Fatalf("update onto a taken identity = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
for _, method := range []string{http.MethodGet, http.MethodPut} {
|
|
var payload any
|
|
if method == http.MethodPut {
|
|
payload = renamed
|
|
}
|
|
if rec, _ := send(method, "/api/v1/bm/materials/999999", payload); rec.Code != http.StatusNotFound {
|
|
t.Errorf("%s of an unknown id = %d %s", method, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
if rec, _ := send(http.MethodDelete, idPath(first["id"]), nil); rec.Code != http.StatusNoContent {
|
|
t.Fatalf("delete = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec, _ := send(http.MethodGet, idPath(first["id"]), nil); rec.Code != http.StatusNotFound {
|
|
t.Errorf("show after delete = %d", rec.Code)
|
|
}
|
|
}
|