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:
@@ -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)
|
||||
|
||||
161
controllers/api/chat_embeds_test.go
Normal file
161
controllers/api/chat_embeds_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user