package bm_test import ( "net/http" "slices" "testing" "git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/jakub/sm-bm-plugin/models" ) // TestMaterialAccessAgainstDatabase checks the visibility rule of private // materials in the student reads (list, detail, course detail and counters) // for the author, a user a material is shared with and another user, and // the author's access endpoints with the claim rule. func TestMaterialAccessAgainstDatabase(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, "author@example.test") bobID := createUser(t, gdb, "bob@example.test") carolID := createUser(t, gdb, "carol@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', '{"jz.bm.access_materials":1}', NOW(), NOW()) RETURNING id`).Scan(&groupID).Error; err != nil { t.Fatal(err) } if err := gdb.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, authorID, groupID).Error; err != nil { t.Fatal(err) } author := apiClient{t: t, h: h, token: tokenFor(t, authorID)} bob := apiClient{t: t, h: h, token: tokenFor(t, bobID)} carol := apiClient{t: t, h: h, token: tokenFor(t, carolID)} const base = "/_bm/api/v1" var course models.Course if err := gdb.Order("id").Take(&course).Error; err != nil { t.Fatal(err) } insert := func(title string, private bool, authorUserID *uint) uint { t.Helper() var id uint err := gdb.Raw(`INSERT INTO jz_bm_materials (course_id, title, type, published_at, is_private, author_user_id) VALUES (?, ?, 'notatka', NOW() - INTERVAL '1 day', ?, ?) RETURNING id`, course.ID, title, private, authorUserID).Scan(&id).Error if err != nil { t.Fatal(err) } return id } pub := insert("Publiczny", false, nil) priv := insert("Prywatny autora", true, &authorID) shared := insert("Udostępniony Bobowi", true, &authorID) if err := gdb.Create(&models.MaterialShare{MaterialID: shared, UserID: bobID}).Error; err != nil { t.Fatal(err) } ids := func(rows []map[string]any) []string { out := make([]string, 0, len(rows)) for _, row := range rows { out = append(out, id(row["id"])) } slices.Sort(out) return out } sorted := func(in ...uint) []string { out := make([]string, 0, len(in)) for _, v := range in { out = append(out, jsonID(v)) } slices.Sort(out) return out } courseMaterials := func(c apiClient) ([]string, float64) { t.Helper() detail := obj(c.ok("GET", base+"/courses/"+jsonID(course.ID))["data"]) raw, _ := detail["materials"].([]any) rows := make([]map[string]any, 0, len(raw)) for _, r := range raw { rows = append(rows, r.(map[string]any)) } n, _ := obj(detail["counts"])["materials"].(float64) return ids(rows), n } for _, tc := range []struct { name string client apiClient want []string }{ {"carol", carol, sorted(pub)}, {"bob", bob, sorted(pub, shared)}, {"author", author, sorted(pub, priv, shared)}, } { if got := ids(list(tc.client.ok("GET", base+"/materials"))); !slices.Equal(got, tc.want) { t.Errorf("%s /materials = %v, want %v", tc.name, got, tc.want) } got, n := courseMaterials(tc.client) if !slices.Equal(got, tc.want) || int(n) != len(tc.want) { t.Errorf("%s course materials = %v (count %v), want %v", tc.name, got, n, tc.want) } } carol.want(http.StatusNotFound, "GET", base+"/materials/"+jsonID(priv), "") bob.want(http.StatusNotFound, "GET", base+"/materials/"+jsonID(priv), "") detail := obj(author.ok("GET", base+"/materials/"+jsonID(priv))["data"]) if detail["can_manage"] != true || detail["is_private"] != true { t.Errorf("author detail = %v, want can_manage and is_private", detail) } if d := obj(bob.ok("GET", base+"/materials/"+jsonID(shared))["data"]); d["can_manage"] != false { t.Errorf("bob detail of the shared material = %v, want can_manage false", d) } // The access endpoints are the author's (a manager) only. access := base + "/materials/" + jsonID(priv) + "/access" carol.want(http.StatusNotFound, "GET", access, "") bob.want(http.StatusNotFound, "GET", access, "") carol.want(http.StatusNotFound, "PUT", access, `{"is_private":false}`) bob.want(http.StatusNotFound, "PUT", base+"/materials/"+jsonID(shared)+"/access", `{"is_private":false}`) got := obj(author.ok("GET", access)["data"]) if got["is_private"] != true { t.Errorf("access = %v", got) } if shares, ok := got["shares"].([]any); !ok || len(shares) != 0 { t.Errorf("shares = %v, want []", got["shares"]) } var users []string for _, u := range got["users"].([]any) { users = append(users, id(u.(map[string]any)["id"])) } if !slices.Contains(users, jsonID(bobID)) || !slices.Contains(users, jsonID(carolID)) || slices.Contains(users, jsonID(authorID)) { t.Errorf("users = %v, want bob and carol without the author", users) } author.want(http.StatusUnprocessableEntity, "PUT", access, `{"user_ids":[999999]}`) author.want(http.StatusUnprocessableEntity, "PUT", access, `{"author_user_id":1}`) put := obj(author.want(http.StatusOK, "PUT", access, `{"user_ids":[`+jsonID(carolID)+`]}`)["data"]) if shares, _ := put["shares"].([]any); len(shares) != 1 || id(obj(shares[0])["id"]) != jsonID(carolID) { t.Errorf("shares after PUT = %v", put["shares"]) } carol.ok("GET", base+"/materials/"+jsonID(priv)) // The claim rule: changing an author-less material makes the caller its // author. pubAccess := base + "/materials/" + jsonID(pub) + "/access" claimed := obj(author.want(http.StatusOK, "PUT", pubAccess, `{"is_private":true}`)["data"]) if claimed["is_private"] != true || id(claimed["author_user_id"]) != jsonID(authorID) { t.Errorf("claimed = %v", claimed) } carol.want(http.StatusNotFound, "GET", base+"/materials/"+jsonID(pub), "") if got := ids(list(carol.ok("GET", base+"/materials"))); !slices.Equal(got, sorted(priv)) { t.Errorf("carol /materials after the claim = %v, want only the shared one", got) } }