package bm_test import ( "encoding/json" "net/http" "testing" "git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/jakub/sm-bm-plugin/models" ) // 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, and that an edit keeps the course and the author. func TestMaterialEditAgainstDatabase(t *testing.T) { app, plugins, gdb := migrated(t) seedFixture(t, plugins, "testdata/seed.example.json") 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"]) } }