feat: private materials with per-user shares
A material may be private: only its author and the users it is shared
with see it. Every student read (list, detail, course detail and
counters, the checklist linked material, the token twins) goes through
one visibility fragment. Existing materials stay public with no author.
GET/PUT /_bm/api/v1/materials/{id}/access let the author, a material
manager, toggle the flag and pick the users; the first change of an
author-less material makes the caller its author.
This commit is contained in:
156
material_access_postgres_test.go
Normal file
156
material_access_postgres_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user