Files
sm-bm-plugin/management_token_test.go
Jakub Zych d2096e2128 feat(manage): GET and PUT /api/v1/bm/materials/{id}
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.
2026-10-16 20:40:47 +02:00

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)
}
}