diff --git a/classes/chat_context_test.go b/classes/chat_context_test.go index 843a834..49f644a 100644 --- a/classes/chat_context_test.go +++ b/classes/chat_context_test.go @@ -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) diff --git a/controllers/api/chat_embeds_test.go b/controllers/api/chat_embeds_test.go new file mode 100644 index 0000000..f5b5a56 --- /dev/null +++ b/controllers/api/chat_embeds_test.go @@ -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) + } +} diff --git a/controllers/api/handlers_test.go b/controllers/api/handlers_test.go index bae042d..086397b 100644 --- a/controllers/api/handlers_test.go +++ b/controllers/api/handlers_test.go @@ -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 {