Files
sm-bm-plugin/material_access_postgres_test.go
Jakub Zych 418f8240a4 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.
2026-11-08 11:52:47 +01:00

157 lines
6.0 KiB
Go

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)
}
}