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, "materials": h.Materials, "material": h.Material, "feed": h.CalendarFeed, "feed-put": h.UpdateCalendarFeed, "rotate": h.RotateCalendarFeed, "feed-del": h.DeleteCalendarFeed, } } 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) } } } 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) } } // 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 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"}, } { 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()) } } }