- GET /_bm/api/v1/tags: the jz_bm_tags dictionary, to material managers
- POST /materials/{id}/unpublish: back to drafts, no revision bump, the
search document resynced
- POST /materials/{id}/images: one JPEG/PNG/GIF/WebP per request through
storeMaterialUpload, refusals with reason type, size or count
- storeMaterialUpload/fileRefusalFor: attach.ErrNotImage is a type
refusal, not a 500; storeMaterialFile keeps its texts
- the author's routes get their own per-user buckets (the inline
throttle form shares one counter per user)
- README: the author routes and revision on the student detail
279 lines
11 KiB
Go
279 lines
11 KiB
Go
package bm_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"image"
|
|
"image/png"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"git.golem15.com/jakub/sm-bm-plugin/models"
|
|
"gocloud.dev/blob"
|
|
_ "gocloud.dev/blob/memblob"
|
|
)
|
|
|
|
// TestMaterialEditAgainstDatabase checks the author's student routes
|
|
// (D-07..D-11, D-16) against Postgres: who may edit (the author; 403 for
|
|
// another manager or a non-manager; 404 for what the caller cannot see),
|
|
// the revision check with the server version in the 409, the edit body's
|
|
// rules, that an edit keeps the course and the author, the tag dictionary,
|
|
// unpublish to drafts and the editor image upload with its refusals.
|
|
func TestMaterialEditAgainstDatabase(t *testing.T) {
|
|
app, plugins, gdb := migrated(t)
|
|
seedFixture(t, plugins, "testdata/seed.example.json")
|
|
bucket, err := blob.OpenBucket(t.Context(), "mem://")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { bucket.Close() })
|
|
if err := app.Publish(bucket); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := surf.Assemble(app, plugins)
|
|
if err != nil {
|
|
t.Fatalf("Assemble: %v", err)
|
|
}
|
|
authorID := createUser(t, gdb, "edit-author@example.test")
|
|
otherID := createUser(t, gdb, "edit-other@example.test")
|
|
plainID := createUser(t, gdb, "edit-plain@example.test")
|
|
var groupID uint
|
|
if err := gdb.Raw(`
|
|
INSERT INTO user_groups (name, code, permissions, created_at, updated_at)
|
|
VALUES ('BM materials', 'bm-materials-edit', '{"jz.bm.access_materials":1}', NOW(), NOW())
|
|
RETURNING id`).Scan(&groupID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, uid := range []uint{authorID, otherID} {
|
|
if err := gdb.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, uid, groupID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
author := apiClient{t: t, h: h, token: tokenFor(t, authorID)}
|
|
other := apiClient{t: t, h: h, token: tokenFor(t, otherID)}
|
|
plain := apiClient{t: t, h: h, token: tokenFor(t, plainID)}
|
|
const base = "/_bm/api/v1"
|
|
|
|
for _, tag := range [][2]string{{"Lektura", "lektura"}, {"Cyber", "cyber"}, {"Wojna", "wojna"}, {"Pokój", "pokoj"}, {"Prawo", "prawo"}} {
|
|
if err := gdb.Exec(`INSERT INTO jz_bm_tags (name, slug, created_at, updated_at) VALUES (?, ?, NOW(), NOW())`, tag[0], tag[1]).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
var course models.Course
|
|
if err := gdb.Order("id").Take(&course).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var meeting models.Meeting
|
|
if err := gdb.Order("number").Take(&meeting).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
insert := func(title string, private, draft bool, authorUserID *uint) uint {
|
|
t.Helper()
|
|
published := "NOW() - INTERVAL '1 day'"
|
|
if draft {
|
|
published = "NULL"
|
|
}
|
|
var id uint
|
|
err := gdb.Raw(`INSERT INTO jz_bm_materials (course_id, title, type, body, published_at, is_private, author_user_id)
|
|
VALUES (?, ?, 'notatka', 'Stara treść', `+published+`, ?, ?) RETURNING id`, course.ID, title, private, authorUserID).Scan(&id).Error
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
own := insert("Notatka autora", false, false, &authorID)
|
|
othersPrivate := insert("Prywatna innego", true, false, &otherID)
|
|
draft := insert("Szkic autora", false, true, &authorID)
|
|
insert("Zajęty tytuł", false, false, &authorID)
|
|
|
|
edit := func(revision uint, mutate func(map[string]any)) string {
|
|
t.Helper()
|
|
b := map[string]any{
|
|
"revision": revision,
|
|
"title": "Nowy tytuł",
|
|
"type": "notatka",
|
|
"body": "\r\n# Nagłówek\r\n\r\ntekst\r\n\r\n",
|
|
"source": nil,
|
|
"url": "https://example.test/a",
|
|
"meeting_id": meeting.ID,
|
|
"tags": []string{"Lektura", "cyber"},
|
|
}
|
|
if mutate != nil {
|
|
mutate(b)
|
|
}
|
|
raw, err := json.Marshal(b)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(raw)
|
|
}
|
|
path := func(id uint) string { return base + "/materials/" + jsonID(id) }
|
|
row := func(id uint) models.Material {
|
|
t.Helper()
|
|
var m models.Material
|
|
if err := gdb.Where("id = ?", id).Take(&m).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// The author saves: revision +1, the body normalised, course and author
|
|
// kept, the meeting and tags as sent.
|
|
saved := obj(author.want(http.StatusOK, "PUT", path(own), edit(1, nil))["data"])
|
|
if saved["revision"] != float64(2) || saved["body"] != "# Nagłówek\n\ntekst\n" || saved["title"] != "Nowy tytuł" {
|
|
t.Errorf("saved = %v, want revision 2 and the normalised body", saved)
|
|
}
|
|
if id(saved["course_id"]) != jsonID(course.ID) || id(saved["meeting_id"]) != jsonID(meeting.ID) {
|
|
t.Errorf("saved course/meeting = %v/%v", saved["course_id"], saved["meeting_id"])
|
|
}
|
|
if tags, _ := saved["tags"].([]any); len(tags) != 2 {
|
|
t.Errorf("saved tags = %v, want 2", saved["tags"])
|
|
}
|
|
if m := row(own); m.AuthorUserID == nil || *m.AuthorUserID != authorID || m.CourseID == nil || *m.CourseID != course.ID || m.PublishedAt == nil || m.IsPrivate {
|
|
t.Errorf("row after the edit = %+v, want author, course and publication kept", m)
|
|
}
|
|
var tagCount int64
|
|
gdb.Model(&models.Tag{}).Count(&tagCount)
|
|
if tagCount != 5 {
|
|
t.Errorf("tags = %d, want the 5 of the dictionary (none created)", tagCount)
|
|
}
|
|
|
|
// A stale revision: 409 with the server version, nothing written.
|
|
conflict := author.want(http.StatusConflict, "PUT", path(own), edit(1, func(b map[string]any) { b["body"] = "Inna treść" }))
|
|
if conflict["revision"] != float64(2) || conflict["error"] != true || conflict["updated_at"] == nil {
|
|
t.Errorf("409 = %v, want revision 2", conflict)
|
|
}
|
|
if d := obj(conflict["data"]); d["body"] != "# Nagłówek\n\ntekst\n" || d["revision"] != float64(2) {
|
|
t.Errorf("409 data = %v, want the stored body", d)
|
|
}
|
|
if m := row(own); m.Body == nil || *m.Body != "# Nagłówek\n\ntekst\n" {
|
|
t.Errorf("body after the 409 = %v", m.Body)
|
|
}
|
|
|
|
// Who may write: 403 for what the caller sees but may not manage, 404
|
|
// for what they cannot see.
|
|
other.want(http.StatusForbidden, "PUT", path(own), edit(2, nil))
|
|
plain.want(http.StatusForbidden, "PUT", path(own), edit(2, nil))
|
|
author.want(http.StatusNotFound, "PUT", path(othersPrivate), edit(1, nil))
|
|
author.want(http.StatusNotFound, "PUT", path(draft), edit(1, nil))
|
|
author.want(http.StatusNotFound, "PUT", path(999999), edit(1, nil))
|
|
|
|
// The edit body's rules.
|
|
for name, mutate := range map[string]func(map[string]any){
|
|
"5 tags": func(b map[string]any) { b["tags"] = []string{"lektura", "cyber", "wojna", "pokoj", "prawo"} },
|
|
"unknown tag": func(b map[string]any) { b["tags"] = []string{"nieznany"} },
|
|
"ftp url": func(b map[string]any) { b["url"] = "ftp://example.test/a" },
|
|
"missing key": func(b map[string]any) { delete(b, "title") },
|
|
"published_at": func(b map[string]any) { b["published_at"] = nil },
|
|
"course_id": func(b map[string]any) { b["course_id"] = course.ID },
|
|
"null tags": func(b map[string]any) { b["tags"] = nil },
|
|
"no revision": func(b map[string]any) { delete(b, "revision") },
|
|
"bad meeting": func(b map[string]any) { b["meeting_id"] = 999999 },
|
|
"taken title": func(b map[string]any) { b["title"] = "Zajęty tytuł" },
|
|
} {
|
|
code, out := author.send("PUT", path(own), edit(2, mutate))
|
|
if code != http.StatusUnprocessableEntity {
|
|
t.Errorf("%s = %d %v, want 422", name, code, out)
|
|
}
|
|
}
|
|
if m := row(own); m.Revision != 2 {
|
|
t.Errorf("revision after the refused edits = %d, want 2", m.Revision)
|
|
}
|
|
|
|
// An unchanged save keeps the revision.
|
|
if again := obj(author.want(http.StatusOK, "PUT", path(own), edit(2, nil))["data"]); again["revision"] != float64(2) {
|
|
t.Errorf("unchanged save revision = %v, want 2", again["revision"])
|
|
}
|
|
|
|
// The tag dictionary: managers only, by name.
|
|
var names []string
|
|
for _, tag := range list(author.ok("GET", base+"/tags")) {
|
|
names = append(names, tag["name"].(string))
|
|
}
|
|
if strings.Join(names, ",") != "Cyber,Lektura,Pokój,Prawo,Wojna" {
|
|
t.Errorf("tags = %v, want the dictionary by name", names)
|
|
}
|
|
plain.want(http.StatusForbidden, "GET", base+"/tags", "")
|
|
|
|
// Editor images: one PNG per request, public, no revision bump.
|
|
pngBytes := func() []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buf.Bytes()
|
|
}()
|
|
upload := func(c apiClient, materialID uint, name string, content []byte) (int, map[string]any) {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
fw, err := mw.CreateFormFile("file", name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fw.Write(content)
|
|
mw.Close()
|
|
req := httptest.NewRequest("POST", path(materialID)+"/images", &buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
rec := httptest.NewRecorder()
|
|
c.h.ServeHTTP(rec, req)
|
|
var out map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("upload %s = %d, not JSON: %s", name, rec.Code, rec.Body.String())
|
|
}
|
|
return rec.Code, out
|
|
}
|
|
code, out := upload(author, own, "obraz.png", pngBytes)
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("PNG upload = %d %v, want 201", code, out)
|
|
}
|
|
if img := obj(out["data"]); !strings.HasPrefix(img["url"].(string), "/storage/app/uploads/public/") || img["mime"] != "image/png" || img["name"] != "obraz.png" {
|
|
t.Errorf("uploaded image = %v", img)
|
|
}
|
|
if m := row(own); m.Revision != 2 {
|
|
t.Errorf("revision after the upload = %d, want 2", m.Revision)
|
|
}
|
|
if code, out := upload(author, own, "image.png", []byte("to nie jest obrazek")); code != http.StatusUnprocessableEntity || out["reason"] != "type" {
|
|
t.Errorf("text as image.png = %d %v, want 422 reason type", code, out)
|
|
}
|
|
if code, out := upload(author, own, "rysunek.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)); code != http.StatusUnprocessableEntity || out["reason"] != "type" {
|
|
t.Errorf("SVG = %d %v, want 422 reason type", code, out)
|
|
}
|
|
if code, out := upload(other, own, "obraz.png", pngBytes); code != http.StatusForbidden {
|
|
t.Errorf("another manager's upload = %d %v, want 403", code, out)
|
|
}
|
|
if code, out := upload(author, othersPrivate, "obraz.png", pngBytes); code != http.StatusNotFound {
|
|
t.Errorf("upload to an invisible material = %d %v, want 404", code, out)
|
|
}
|
|
for i := 1; i < 20; i++ {
|
|
if code, out := upload(author, own, "obraz.png", pngBytes); code != http.StatusCreated {
|
|
t.Fatalf("upload %d = %d %v, want 201", i+1, code, out)
|
|
}
|
|
}
|
|
if code, out := upload(author, own, "obraz.png", pngBytes); code != http.StatusUnprocessableEntity || out["reason"] != "count" {
|
|
t.Errorf("21st file = %d %v, want 422 reason count", code, out)
|
|
}
|
|
if d := obj(author.ok("GET", path(own))["data"]); d["files_count"] != float64(20) {
|
|
t.Errorf("files_count = %v, want 20", d["files_count"])
|
|
}
|
|
|
|
// Unpublish: a draft, 404 even for the author afterwards, revision kept.
|
|
other.want(http.StatusForbidden, "POST", path(own)+"/unpublish", "")
|
|
author.want(http.StatusNotFound, "POST", path(othersPrivate)+"/unpublish", "")
|
|
if code, _ := author.send("POST", path(own)+"/unpublish", ""); code != http.StatusNoContent {
|
|
t.Fatalf("unpublish = %d, want 204", code)
|
|
}
|
|
author.want(http.StatusNotFound, "GET", path(own), "")
|
|
if m := row(own); m.PublishedAt != nil || m.Revision != 2 || m.AuthorUserID == nil || *m.AuthorUserID != authorID {
|
|
t.Errorf("row after unpublish = %+v, want a draft at revision 2 with its author", m)
|
|
}
|
|
author.want(http.StatusNotFound, "POST", path(own)+"/unpublish", "")
|
|
author.want(http.StatusNotFound, "PUT", path(own), edit(2, nil))
|
|
}
|