Files
sm-bm-plugin/controllers/api/handlers_test.go

180 lines
6.1 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,
"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())
}
}
}