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.
231 lines
7.9 KiB
Go
231 lines
7.9 KiB
Go
package api_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// noDB is a Handlers whose database is never published, so a request that
|
|
// gets past authentication and validation answers 503.
|
|
func noDB() *api.Handlers {
|
|
return &api.Handlers{DB: func() *gorm.DB { return nil }}
|
|
}
|
|
|
|
func serve(t *testing.T, h http.HandlerFunc, method, target string, user *bouncer.Principal, pathValues map[string]string) (int, map[string]any) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, target, nil)
|
|
if user != nil {
|
|
req = req.WithContext(bouncer.WithUser(req.Context(), user))
|
|
}
|
|
for k, v := range pathValues {
|
|
req.SetPathValue(k, v)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h(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", method, target, err, rec.Body.String())
|
|
}
|
|
return rec.Code, body
|
|
}
|
|
|
|
func handlers(h *api.Handlers) map[string]http.HandlerFunc {
|
|
return map[string]http.HandlerFunc{
|
|
"dashboard": h.Dashboard,
|
|
"courses": h.Courses,
|
|
"course": h.Course,
|
|
"schedule": h.Schedule,
|
|
"meetings": h.Meetings,
|
|
"deadlines": h.Deadlines,
|
|
"readings": h.Readings,
|
|
"mark": h.MarkRead,
|
|
"unmark": h.UnmarkRead,
|
|
"own": h.MarkOwned,
|
|
"unown": h.UnmarkOwned,
|
|
"materials": h.Materials,
|
|
"material": h.Material,
|
|
"mat-access": h.MaterialAccess,
|
|
"feed": h.CalendarFeed,
|
|
"feed-put": h.UpdateCalendarFeed,
|
|
"rotate": h.RotateCalendarFeed,
|
|
"feed-del": h.DeleteCalendarFeed,
|
|
"chat": h.Chat,
|
|
"chat-mem": h.ChatMembers,
|
|
}
|
|
}
|
|
|
|
// 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}]}`},
|
|
"mat-access-put": {h.UpdateMaterialAccess, http.MethodPut, "/", `{"is_private":true,"user_ids":[]}`},
|
|
}
|
|
}
|
|
|
|
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"})
|
|
if code != http.StatusUnauthorized || body["error"] != true {
|
|
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) {
|
|
user := &bouncer.Principal{ID: 7}
|
|
for name, fn := range handlers(noDB()) {
|
|
code, body := serve(t, fn, http.MethodGet, "/", user, map[string]string{"id": "1"})
|
|
if code != http.StatusServiceUnavailable {
|
|
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 {
|
|
t.Errorf("nil DB func = %d, want 503", code)
|
|
}
|
|
}
|
|
|
|
func TestQueryValidation(t *testing.T) {
|
|
h := noDB()
|
|
user := &bouncer.Principal{ID: 7}
|
|
cases := []struct {
|
|
name string
|
|
fn http.HandlerFunc
|
|
target string
|
|
field string
|
|
}{
|
|
{"schedule from not a date", h.Schedule, "/?from=jutro", "from"},
|
|
{"schedule from impossible date", h.Schedule, "/?from=2026-02-30", "from"},
|
|
{"schedule from not ISO", h.Schedule, "/?from=03.10.2026", "from"},
|
|
{"schedule to before from", h.Schedule, "/?from=2026-10-10&to=2026-10-01", "to"},
|
|
{"deadlines to not a date", h.Deadlines, "/?to=10/01/2026", "to"},
|
|
{"readings course not a number", h.Readings, "/?course=abc", "course"},
|
|
{"readings course zero", h.Readings, "/?course=0", "course"},
|
|
{"courses semester out of range", h.Courses, "/?semester=9", "semester"},
|
|
{"materials type unknown", h.Materials, "/?type=video", "type"},
|
|
{"materials page zero", h.Materials, "/?page=0", "page"},
|
|
{"materials per_page too large", h.Materials, "/?per_page=1000", "per_page"},
|
|
{"materials course negative", h.Materials, "/?course=-1", "course"},
|
|
{"materials meeting zero", h.Materials, "/?meeting=0", "meeting"},
|
|
{"materials tag too long", h.Materials, "/?tag=" + longTag(), "tag"},
|
|
}
|
|
for _, tc := range cases {
|
|
code, body := serve(t, tc.fn, http.MethodGet, tc.target, user, nil)
|
|
if code != http.StatusUnprocessableEntity {
|
|
t.Errorf("%s: status %d %v, want 422", tc.name, code, body)
|
|
continue
|
|
}
|
|
errs, _ := body["errors"].(map[string]any)
|
|
if msgs, _ := errs[tc.field].([]any); len(msgs) == 0 {
|
|
t.Errorf("%s: errors %v have no %s message", tc.name, errs, tc.field)
|
|
}
|
|
if msg, _ := body["message"].(string); msg == "" {
|
|
t.Errorf("%s: no message in %v", tc.name, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func longTag() string {
|
|
b := make([]byte, 101)
|
|
for i := range b {
|
|
b[i] = 'a'
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func TestValidQueriesReachTheDatabase(t *testing.T) {
|
|
h := noDB()
|
|
user := &bouncer.Principal{ID: 7}
|
|
for _, tc := range []struct {
|
|
fn http.HandlerFunc
|
|
target string
|
|
}{
|
|
{h.Schedule, "/?from=2026-10-01&to=2026-10-31"},
|
|
{h.Schedule, "/?to=2026-10-31"},
|
|
{h.Schedule, "/?from=2026-10-01&to=2026-10-01"},
|
|
{h.Deadlines, "/"},
|
|
{h.Readings, "/?course=3"},
|
|
{h.Courses, "/?semester=2"},
|
|
{h.Materials, "/?course=1&tag=geopolityka&type=pdf&page=2&per_page=10"},
|
|
{h.Materials, "/?meeting=4&per_page=100"},
|
|
} {
|
|
if code, body := serve(t, tc.fn, http.MethodGet, tc.target, user, nil); code != http.StatusServiceUnavailable {
|
|
t.Errorf("%s = %d %v, want to pass validation (503 without a database)", tc.target, code, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCalendarICSRejectsMalformedTokens(t *testing.T) {
|
|
h := noDB()
|
|
good := strings.Repeat("ab", 32)
|
|
for _, token := range []string{"", "abc", good[:63], good + "0", strings.ToUpper(good), good[:62] + "zz", good + ".ical", "../" + good} {
|
|
code, body := serve(t, h.CalendarICS, http.MethodGet, "/", nil, map[string]string{"token": token})
|
|
if code != http.StatusNotFound {
|
|
t.Errorf("token %q = %d %v, want 404 without a database lookup", token, code, body)
|
|
}
|
|
}
|
|
// A well-formed token, with or without .ics, is looked up (503 here).
|
|
for _, token := range []string{good, good + ".ics"} {
|
|
if code, _ := serve(t, h.CalendarICS, http.MethodGet, "/", nil, map[string]string{"token": token}); code != http.StatusServiceUnavailable {
|
|
t.Errorf("token %q = %d, want 503 without a database", token, code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUpdateCalendarFeedRejectsAnInvalidBody(t *testing.T) {
|
|
h := noDB()
|
|
for _, body := range []string{"[1,2]", "{", `"x"`} {
|
|
req := httptest.NewRequest(http.MethodPut, "/", strings.NewReader(body))
|
|
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: 7}))
|
|
rec := httptest.NewRecorder()
|
|
h.UpdateCalendarFeed(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("body %s = %d %s, want 400", body, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|