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(``)); 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)) }