- every revision bump (API writes and /bmadmin saves) enqueues
updated.jz.bm.material with {material_id, revision} in a savepoint
- the materials namespace authorizer allows a subscribe only to a
visible material the user may manage
- api.ManagesMaterials and api.CanWatchMaterial
186 lines
6.0 KiB
Go
186 lines
6.0 KiB
Go
package bm_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"git.golem15.com/jakub/sm-bm-plugin/classes"
|
|
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
|
|
"git.golem15.com/jakub/sm-bm-plugin/models"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// The realtime config of the material watch test: the Centrifugo driver, so
|
|
// Emit enqueues broadcast jobs.
|
|
const (
|
|
watchProxySecret = "material-watch-proxy-secret-0123456789"
|
|
watchTokenSecret = "bm-test-only-material-watch-token-secret-32b"
|
|
watchAppKey = "bm-test-only-material-watch-app-key"
|
|
)
|
|
|
|
// TestMaterialWatchAgainstDatabase checks the live revision heads-up (D-20)
|
|
// against Postgres: who may follow materials:{id} (api.CanWatchMaterial),
|
|
// and that a management PUT which changes the content queues exactly one
|
|
// broadcast on the material's channel while a visibility-only PUT queues
|
|
// none.
|
|
func TestMaterialWatchAgainstDatabase(t *testing.T) {
|
|
app, plugins, db := migratedWith(t,
|
|
"SUMMER_REALTIME__DRIVER=centrifugo",
|
|
"SUMMER_REALTIME__CENTRIFUGO__PROXY_SECRET="+watchProxySecret,
|
|
"SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET="+watchTokenSecret,
|
|
"SUMMER_APP__KEY="+watchAppKey,
|
|
)
|
|
seedFixture(t, plugins, "testdata/seed.example.json")
|
|
var course models.Course
|
|
if err := db.Order("id").First(&course).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
author := createUser(t, db, "bm-watch-author@example.test")
|
|
other := createUser(t, db, "bm-watch-other@example.test")
|
|
student := createUser(t, db, "bm-watch-student@example.test")
|
|
var groupID uint
|
|
if err := db.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)
|
|
}
|
|
for _, id := range []uint{author, other} {
|
|
if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, id, groupID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
now := time.Now()
|
|
past := now.Add(-time.Hour)
|
|
insert := func(title string, published *time.Time, private bool, authorID *uint) uint {
|
|
t.Helper()
|
|
var id uint
|
|
err := db.Raw(`
|
|
INSERT INTO jz_bm_materials (course_id, title, type, body, published_at, is_private, author_user_id, created_at, updated_at)
|
|
VALUES (?, ?, 'notatka', 'tekst', ?, ?, ?, NOW(), NOW())
|
|
RETURNING id`, course.ID, title, published, private, authorID).Scan(&id).Error
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
authored := insert("Autorska", &past, false, &author)
|
|
orphan := insert("Bez autora", &past, false, nil)
|
|
hidden := insert("Prywatna", &past, true, nil)
|
|
draft := insert("Szkic", nil, false, nil)
|
|
|
|
for _, c := range []struct {
|
|
name string
|
|
userID uint
|
|
id uint
|
|
want bool
|
|
}{
|
|
{"the author manager", author, authored, true},
|
|
{"another manager on an authored material", other, authored, false},
|
|
{"any manager on a material with no author", other, orphan, true},
|
|
{"a non-manager", student, orphan, false},
|
|
{"a private material the user cannot see", other, hidden, false},
|
|
{"a draft", other, draft, false},
|
|
{"an unknown material", author, draft + 1000, false},
|
|
} {
|
|
got, err := api.CanWatchMaterial(t.Context(), db, now, c.userID, c.id)
|
|
if err != nil {
|
|
t.Errorf("%s: %v", c.name, err)
|
|
continue
|
|
}
|
|
if got != c.want {
|
|
t.Errorf("%s: CanWatchMaterial = %v, want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
|
|
secret, token, err := userclasses.MintAPIToken("usr_", author, "BM watch", []string{"read", "write"}, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(token).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
handler, err := surf.Assemble(app, plugins)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
send := func(method, path string, payload any) (int, map[string]any) {
|
|
t.Helper()
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(method, path, bytes.NewReader(raw))
|
|
req.Header.Set("Authorization", "Bearer "+secret)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
var out map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
|
return rec.Code, out
|
|
}
|
|
|
|
note := map[string]any{"title": "Na żywo", "type": "notatka", "course_id": course.ID, "body": "pierwsza\n"}
|
|
code, out := send(http.MethodPost, "/api/v1/bm/materials", note)
|
|
if code != http.StatusCreated {
|
|
t.Fatalf("create = %d %v", code, out)
|
|
}
|
|
d, _ := out["data"].(map[string]any)
|
|
id := uint(d["id"].(float64))
|
|
path := "/api/v1/bm/materials/" + strconv.FormatUint(uint64(id), 10)
|
|
channel := classes.MaterialChannel(id)
|
|
if n := revisionJobs(t, db, channel); n != 0 {
|
|
t.Fatalf("create queued %d revision broadcasts, want 0", n)
|
|
}
|
|
|
|
note["body"] = "druga\n"
|
|
if code, out := send(http.MethodPut, path, note); code != http.StatusOK {
|
|
t.Fatalf("content PUT = %d %v", code, out)
|
|
}
|
|
if n := revisionJobs(t, db, channel); n != 1 {
|
|
t.Fatalf("content PUT queued %d revision broadcasts on %s, want 1", n, channel)
|
|
}
|
|
|
|
note["is_private"] = true
|
|
if code, out := send(http.MethodPut, path, note); code != http.StatusOK {
|
|
t.Fatalf("visibility PUT = %d %v", code, out)
|
|
}
|
|
if n := revisionJobs(t, db, channel); n != 1 {
|
|
t.Errorf("visibility PUT changed the revision broadcasts to %d, want still 1", n)
|
|
}
|
|
}
|
|
|
|
// revisionJobs counts the queued broadcast jobs that announce a material
|
|
// revision on channel.
|
|
func revisionJobs(t *testing.T, db *gorm.DB, channel string) int {
|
|
t.Helper()
|
|
var rows []string
|
|
if err := db.Raw(`SELECT args::text FROM river_job WHERE kind = 'summer.broadcast'`).Scan(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n := 0
|
|
for _, raw := range rows {
|
|
var args struct {
|
|
Channels []string `json:"channels"`
|
|
Event string `json:"event"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if args.Event == classes.MaterialRevisedEvent && slices.Contains(args.Channels, channel) {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|