test: cover chat moderation and the embed endpoints

- CanModerateMessage: group conversations only, chat members only
- parseEmbedRefs, embedKey, escapeLike
- /chat/embeds and /chat/embeds/resolve: 401, 422 before the
  database, 503 without one
This commit is contained in:
2026-10-26 16:05:33 +01:00
parent eea171cee6
commit ac4c2e6f73
3 changed files with 233 additions and 0 deletions

View File

@@ -96,6 +96,35 @@ func TestChatContext(t *testing.T) {
t.Error("CanActFor is not self-only")
}
// Moderators delete other people's messages in group conversations
// (# ogólny) only, and only while they are chat-group members too
// (D-10, D-24).
for _, mc := range []struct {
name string
userID uint
convType string
want bool
}{
{"moderator in group", 7, "group", true},
{"moderator in dm", 7, "dm", false},
{"moderator, unknown type", 7, "", false},
{"non-moderator in group", 8, "group", false},
{"moderator outside the chat group", 9, "group", false},
{"no user", 0, "group", false},
} {
got, err := c.CanModerateMessage(ctx, mc.userID, mc.convType)
if err != nil || got != mc.want {
t.Errorf("%s: CanModerateMessage = %v, %v, want %v", mc.name, got, err, mc.want)
}
}
if _, err := c.CanModerateMessage(ctx, 13, "group"); err == nil {
t.Error("CanModerateMessage with a failing check returned no error")
}
noMods := classes.NewChatContext(&gorm.DB{}, "studenci", "").WithMembership(members)
if ok, err := noMods.CanModerateMessage(ctx, 7, "group"); ok || err != nil {
t.Errorf("CanModerateMessage without a moderator group = %v, %v, want false", ok, err)
}
load := classes.ChatContextLoader(func() string { return "studenci" }, func() string { return "moderatorzy" })
if got, err := load(ctx, &gorm.DB{}, 2); got != nil || err != nil {
t.Errorf("loader(2) = %v, %v, want nil, nil", got, err)

View File

@@ -0,0 +1,161 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"gorm.io/gorm"
)
func embedHandlers() *Handlers {
return &Handlers{DB: func() *gorm.DB { return nil }, ChatGroup: "studenci", ChatModeratorGroup: "moderatorzy"}
}
func serveEmbed(t *testing.T, fn http.HandlerFunc, method, target, body string, userID uint) (int, map[string]any) {
t.Helper()
req := httptest.NewRequest(method, target, strings.NewReader(body))
if userID != 0 {
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: userID}))
}
rec := httptest.NewRecorder()
fn(rec, req)
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("%s %s: body is not JSON: %v\n%s", method, target, err, rec.Body.String())
}
return rec.Code, out
}
// TestChatEmbedRefs: the resolver accepts deadline, class and reading refs
// with a positive id, collapses duplicates and takes at most 50 distinct
// refs; materials and quizzes are not embed types (D-14).
func TestChatEmbedRefs(t *testing.T) {
ok := []embedRef{{"deadline", 1}, {"class", 2}, {"reading", 3}, {"deadline", 1}}
got, errs := parseEmbedRefs(ok)
if len(errs) != 0 {
t.Fatalf("valid refs refused: %v", errs)
}
if want := []embedRef{{"deadline", 1}, {"class", 2}, {"reading", 3}}; fmt.Sprint(got) != fmt.Sprint(want) {
t.Errorf("refs = %v, want %v (duplicates collapse, order kept)", got, want)
}
for name, refs := range map[string][]embedRef{
"material": {{"material", 1}},
"quiz": {{"quiz", 1}},
"empty": {{"", 1}},
"id 0": {{"deadline", 0}},
"51 refs": manyRefs(51),
} {
if _, errs := parseEmbedRefs(refs); len(errs) == 0 {
t.Errorf("%s: accepted, want a validation error", name)
}
}
if _, errs := parseEmbedRefs(manyRefs(50)); len(errs) != 0 {
t.Errorf("50 refs refused: %v", errs)
}
dup := append(manyRefs(50), manyRefs(50)...)
if got, errs := parseEmbedRefs(dup); len(errs) != 0 || len(got) != 50 {
t.Errorf("50 distinct refs sent twice = %d refs, %v; want 50 and no error", len(got), errs)
}
if embedKey("deadline", 12) != "deadline:12" || embedKey("class", 3) != "class:3" {
t.Errorf("embedKey = %q / %q", embedKey("deadline", 12), embedKey("class", 3))
}
if embedSearchLimit != 20 {
t.Errorf("embedSearchLimit = %d, want 20", embedSearchLimit)
}
}
func manyRefs(n int) []embedRef {
out := make([]embedRef, n)
for i := range out {
out[i] = embedRef{Type: "reading", ID: uint(i + 1)}
}
return out
}
// TestEscapeLike: %, _ and the backslash escape character are matched
// literally in an ILIKE pattern.
func TestEscapeLike(t *testing.T) {
for in, want := range map[string]string{
`50%_a\b`: `50\%\_a\\b`,
"plain": "plain",
"": "",
"Wstęp_100": `Wstęp\_100`,
} {
if got := escapeLike(in); got != want {
t.Errorf("escapeLike(%q) = %q, want %q", in, got, want)
}
}
}
func TestChatEmbedsUnauthenticated(t *testing.T) {
code, body := serveEmbed(t, embedHandlers().ChatEmbeds, http.MethodGet, "/?type=deadline", "", 0)
if code != http.StatusUnauthorized || body["error"] != true {
t.Errorf("GET /chat/embeds without a principal = %d %v, want 401", code, body)
}
}
func TestResolveChatEmbedsUnauthenticated(t *testing.T) {
code, body := serveEmbed(t, embedHandlers().ResolveChatEmbeds, http.MethodPost, "/", `{"refs":[{"type":"deadline","id":1}]}`, 0)
if code != http.StatusUnauthorized || body["error"] != true {
t.Errorf("POST /chat/embeds/resolve without a principal = %d %v, want 401", code, body)
}
}
// TestChatEmbedsInvalid: the search validates type and q before the
// database (422 without one).
func TestChatEmbedsInvalid(t *testing.T) {
h := embedHandlers()
for target, field := range map[string]string{
"/": "type",
"/?type=material": "type",
"/?type=deadline&q=" + strings.Repeat("ą", 101): "q",
} {
code, body := serveEmbed(t, h.ChatEmbeds, http.MethodGet, target, "", 7)
if code != http.StatusUnprocessableEntity {
t.Errorf("%s = %d %v, want 422", target, code, body)
continue
}
if errs, _ := body["errors"].(map[string]any); errs[field] == nil {
t.Errorf("%s: errors %v have no %s", target, body["errors"], field)
}
}
code, _ := serveEmbed(t, h.ChatEmbeds, http.MethodGet, "/?type=reading&q="+strings.Repeat("ą", 100), "", 7)
if code != http.StatusServiceUnavailable {
t.Errorf("100-rune q = %d, want to pass validation (503 without a database)", code)
}
}
// TestResolveChatEmbedsInvalid: an unsupported type, id 0, too many refs,
// an unknown field or a malformed body is 422 before the database.
func TestResolveChatEmbedsInvalid(t *testing.T) {
h := embedHandlers()
many, _ := json.Marshal(map[string]any{"refs": manyRefs(51)})
for name, body := range map[string]string{
"material": `{"refs":[{"type":"material","id":1}]}`,
"id 0": `{"refs":[{"type":"deadline","id":0}]}`,
"51 refs": string(many),
"unknown field": `{"refs":[],"extra":1}`,
"not an object": `[1]`,
"malformed": `{`,
"empty body": ``,
} {
code, out := serveEmbed(t, h.ResolveChatEmbeds, http.MethodPost, "/", body, 7)
if code != http.StatusUnprocessableEntity {
t.Errorf("%s = %d %v, want 422", name, code, out)
}
}
}
// TestResolveChatEmbedsNoDatabase: valid refs pass validation and need the
// database (503 without one).
func TestResolveChatEmbedsNoDatabase(t *testing.T) {
code, body := serveEmbed(t, embedHandlers().ResolveChatEmbeds, http.MethodPost, "/", `{"refs":[{"type":"deadline","id":1},{"type":"class","id":2},{"type":"reading","id":3}]}`, 7)
if code != http.StatusServiceUnavailable {
t.Errorf("valid refs without a database = %d %v, want 503", code, body)
}
}

View File

@@ -60,6 +60,37 @@ func handlers(h *api.Handlers) map[string]http.HandlerFunc {
}
}
// bodyRequest is a handler that validates its query or body before the
// database, with a request that passes that validation.
type bodyRequest struct {
fn http.HandlerFunc
method string
target string
body string
}
func bodyHandlers(h *api.Handlers) map[string]bodyRequest {
return map[string]bodyRequest{
"chat-embeds": {h.ChatEmbeds, http.MethodGet, "/?type=deadline&q=esej", ""},
"chat-resolve": {h.ResolveChatEmbeds, http.MethodPost, "/", `{"refs":[{"type":"reading","id":1}]}`},
}
}
func serveBody(t *testing.T, b bodyRequest, user *bouncer.Principal) (int, map[string]any) {
t.Helper()
req := httptest.NewRequest(b.method, b.target, strings.NewReader(b.body))
if user != nil {
req = req.WithContext(bouncer.WithUser(req.Context(), user))
}
rec := httptest.NewRecorder()
b.fn(rec, req)
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("%s %s: body is not JSON: %v\n%s", b.method, b.target, err, rec.Body.String())
}
return rec.Code, body
}
func TestEveryHandlerNeedsACaller(t *testing.T) {
for name, fn := range handlers(noDB()) {
code, body := serve(t, fn, http.MethodGet, "/", nil, map[string]string{"id": "1"})
@@ -67,6 +98,12 @@ func TestEveryHandlerNeedsACaller(t *testing.T) {
t.Errorf("%s without a principal = %d %v, want 401", name, code, body)
}
}
for name, b := range bodyHandlers(noDB()) {
code, body := serveBody(t, b, nil)
if code != http.StatusUnauthorized || body["error"] != true {
t.Errorf("%s without a principal = %d %v, want 401", name, code, body)
}
}
}
func TestEveryHandlerNeedsADatabase(t *testing.T) {
@@ -77,6 +114,12 @@ func TestEveryHandlerNeedsADatabase(t *testing.T) {
t.Errorf("%s without a database = %d %v, want 503", name, code, body)
}
}
for name, b := range bodyHandlers(noDB()) {
code, body := serveBody(t, b, user)
if code != http.StatusServiceUnavailable {
t.Errorf("%s without a database = %d %v, want 503", name, code, body)
}
}
// A nil DB func is the same as an unpublished database.
code, _ := serve(t, (&api.Handlers{}).Meetings, http.MethodGet, "/", user, nil)
if code != http.StatusServiceUnavailable {