From b39f627fe3c91262213dd110dc750538fdf98dbc Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 17:38:48 +0200 Subject: [PATCH] test: cover the ICS writer and the calendar feed routes --- calendar_postgres_test.go | 276 +++++++++++++++++++++++++++++++ classes/calendar_test.go | 213 ++++++++++++++++++++++++ classes/ics_test.go | 115 +++++++++++++ controllers/api/handlers_test.go | 35 ++++ plugin_test.go | 29 +++- postgres_test.go | 2 +- 6 files changed, 665 insertions(+), 5 deletions(-) create mode 100644 calendar_postgres_test.go create mode 100644 classes/calendar_test.go create mode 100644 classes/ics_test.go diff --git a/calendar_postgres_test.go b/calendar_postgres_test.go new file mode 100644 index 0000000..ae1ac93 --- /dev/null +++ b/calendar_postgres_test.go @@ -0,0 +1,276 @@ +package bm_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/surf" + "git.golem15.com/jakub/sm-bm-plugin/models" + "gorm.io/gorm" +) + +// send is apiClient.do with a JSON body. +func (c apiClient) send(method, path, body string) (int, map[string]any) { + c.t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + rec := httptest.NewRecorder() + c.h.ServeHTTP(rec, req) + var out map[string]any + if rec.Code != http.StatusNoContent { + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + c.t.Fatalf("%s %s = %d, not JSON: %s", method, path, rec.Code, rec.Body.String()) + } + } + return rec.Code, out +} + +// fetchICS GETs a feed URL (absolute or a path) without credentials. +func fetchICS(t *testing.T, h http.Handler, url string, header ...string) *httptest.ResponseRecorder { + t.Helper() + path := url + if i := strings.Index(url, "/_bm/"); i >= 0 { + path = url[i:] + } + req := httptest.NewRequest(http.MethodGet, path, nil) + req.RemoteAddr = "198.51.100.7:4000" + for i := 0; i+1 < len(header); i += 2 { + req.Header.Set(header[i], header[i+1]) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func feedURL(t *testing.T, body map[string]any) string { + t.Helper() + u, _ := obj(body["data"])["url"].(string) + if u == "" { + t.Fatalf("no feed url in %v", body) + } + return u +} + +func TestCalendarFeedAgainstDatabase(t *testing.T) { + app, plugins, gdb := migrated(t) + seedFixture(t, plugins, "testdata/seed.example.json") + if err := app.Config.Set("jz.bm.public_url", "https://bm.example.test/"); err != nil { + t.Fatal(err) + } + h, err := surf.Assemble(app, plugins) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + aliceID := createUser(t, gdb, "alice@example.test") + alice := apiClient{t: t, h: h, token: tokenFor(t, aliceID)} + bob := apiClient{t: t, h: h, token: tokenFor(t, createUser(t, gdb, "bob@example.test"))} + guest := apiClient{t: t, h: h} + + if code, _ := guest.do("GET", "/_bm/api/v1/calendar-feed"); code != http.StatusUnauthorized { + t.Errorf("guest GET /calendar-feed = %d, want 401", code) + } + if body := alice.ok("GET", "/_bm/api/v1/calendar-feed"); body["data"] != nil { + t.Errorf("feed before creation = %v, want null", body) + } + if code, _ := alice.do("POST", "/_bm/api/v1/calendar-feed/rotate"); code != http.StatusNotFound { + t.Errorf("rotate without a feed = %d, want 404", code) + } + + var konw models.Course + gdb.Where("form = ?", "konwersatorium").Take(&konw) + + // Creating with defaults. + code, body := alice.send("PUT", "/_bm/api/v1/calendar-feed", `{}`) + if code != http.StatusOK { + t.Fatalf("PUT = %d %v", code, body) + } + data := obj(body["data"]) + aliceURL := feedURL(t, body) + if !strings.HasPrefix(aliceURL, "https://bm.example.test/_bm/calendar/") || !strings.HasSuffix(aliceURL, ".ics") || len(aliceURL) != len("https://bm.example.test/_bm/calendar/")+64+4 { + t.Errorf("url = %q", aliceURL) + } + if data["include_meetings"] != true || data["include_deadlines"] != true || data["last_fetched_at"] != nil { + t.Errorf("defaults = %v", data) + } + if ids, ok := data["excluded_course_ids"].([]any); !ok || len(ids) != 0 { + t.Errorf("excluded_course_ids = %#v, want []", data["excluded_course_ids"]) + } + + // Validation: an unknown course, a non-integer, a non-array, a non-boolean. + for _, bad := range []struct{ body, field string }{ + {`{"excluded_course_ids": [999999]}`, "excluded_course_ids.0"}, + {`{"excluded_course_ids": [1.5]}`, "excluded_course_ids.0"}, + {`{"excluded_course_ids": "1"}`, "excluded_course_ids"}, + {`{"include_meetings": "tak"}`, "include_meetings"}, + } { + code, body := alice.send("PUT", "/_bm/api/v1/calendar-feed", bad.body) + if code != http.StatusUnprocessableEntity || obj(body["errors"])[bad.field] == nil { + t.Errorf("%s = %d %v, want 422 on %s", bad.body, code, body, bad.field) + } + } + + // Updating keeps the token; duplicates are dropped. + idStr := id(float64(konw.ID)) + code, body = alice.send("PUT", "/_bm/api/v1/calendar-feed", `{"excluded_course_ids": [`+idStr+`, `+idStr+`], "include_deadlines": false}`) + if code != http.StatusOK || feedURL(t, body) != aliceURL { + t.Fatalf("update = %d %v", code, body) + } + data = obj(body["data"]) + if ids := data["excluded_course_ids"].([]any); len(ids) != 1 || id(ids[0]) != idStr || data["include_deadlines"] != false || data["include_meetings"] != true { + t.Errorf("after update = %v", data) + } + + // The feed itself: public, with the right headers and content. + rec := fetchICS(t, h, aliceURL) + if rec.Code != http.StatusOK { + t.Fatalf("GET feed = %d %s", rec.Code, rec.Body.String()) + } + hdr := rec.Header() + if hdr.Get("Content-Type") != "text/calendar; charset=utf-8" || hdr.Get("Content-Disposition") != `inline; filename="bm.ics"` || hdr.Get("Cache-Control") != "private, max-age=900" { + t.Errorf("headers = %v", hdr) + } + ics := rec.Body.String() + unfolded := strings.ReplaceAll(ics, "\r\n ", "") + if !strings.HasPrefix(ics, "BEGIN:VCALENDAR\r\n") || !strings.HasSuffix(ics, "END:VCALENDAR\r\n") { + t.Errorf("not a calendar: %q", ics) + } + var sessA models.ClassSession + gdb.Where("room = ?", "101").Take(&sessA) + for _, want := range []string{ + "UID:session-" + id(float64(sessA.ID)) + "@bm.jakubzych.com", + "DTSTART:20301005T060000Z", + "SUMMARY:Przedmiot przykładowy A – wykład\r\n", + "https://bm.example.test/przedmioty/", + "SUMMARY:1. zjazd\r\n", + "DTSTART;VALUE=DATE:20301005", "DTEND;VALUE=DATE:20301007", + } { + if !strings.Contains(unfolded, want) { + t.Errorf("feed lacks %q:\n%s", want, unfolded) + } + } + for _, unwanted := range []string{"Przedmiot przykładowy B", "Termin:"} { + if strings.Contains(unfolded, unwanted) { + t.Errorf("feed has %q although it is excluded:\n%s", unwanted, unfolded) + } + } + // A revalidation with the ETag is a 304. + if rec2 := fetchICS(t, h, aliceURL, "If-None-Match", hdr.Get("ETag")); rec2.Code != http.StatusNotModified { + t.Errorf("If-None-Match = %d, want 304", rec2.Code) + } + // Without .ics the feed answers too. + if rec := fetchICS(t, h, strings.TrimSuffix(aliceURL, ".ics")); rec.Code != http.StatusOK { + t.Errorf("without .ics = %d", rec.Code) + } + + // last_fetched_at is set by the fetch and written at most once a minute. + body = alice.ok("GET", "/_bm/api/v1/calendar-feed") + if obj(body["data"])["last_fetched_at"] == nil { + t.Errorf("last_fetched_at not set: %v", body) + } + var stamped time.Time + gdb.Raw(`SELECT last_fetched_at FROM jz_bm_calendar_feeds WHERE user_id = ?`, aliceID).Scan(&stamped) + gdb.Exec(`UPDATE jz_bm_calendar_feeds SET last_fetched_at = last_fetched_at - interval '30 seconds' WHERE user_id = ?`, aliceID) + fetchICS(t, h, aliceURL) + var again time.Time + gdb.Raw(`SELECT last_fetched_at FROM jz_bm_calendar_feeds WHERE user_id = ?`, aliceID).Scan(&again) + if !again.Equal(stamped.Add(-30 * time.Second)) { + t.Errorf("last_fetched_at rewritten within a minute: %v -> %v", stamped, again) + } + + // Feeds are per user: Bob's feed lists what Alice excluded, and his + // settings do not touch hers. + code, body = bob.send("PUT", "/_bm/api/v1/calendar-feed", `{"include_meetings": false}`) + if code != http.StatusOK { + t.Fatalf("bob PUT = %d %v", code, body) + } + bobURL := feedURL(t, body) + if bobURL == aliceURL { + t.Fatal("bob got alice's token") + } + bobICS := strings.ReplaceAll(fetchICS(t, h, bobURL).Body.String(), "\r\n ", "") + if !strings.Contains(bobICS, "Przedmiot przykładowy B") || !strings.Contains(bobICS, "Termin: Termin przykładowy") || strings.Contains(bobICS, ". zjazd") { + t.Errorf("bob's feed:\n%s", bobICS) + } + if data := obj(alice.ok("GET", "/_bm/api/v1/calendar-feed")["data"]); data["include_meetings"] != true || len(data["excluded_course_ids"].([]any)) != 1 { + t.Errorf("alice's feed changed by bob: %v", data) + } + + // Rotating: the old URL is gone, the new one works. + code, body = alice.send("POST", "/_bm/api/v1/calendar-feed/rotate", "") + if code != http.StatusOK { + t.Fatalf("rotate = %d %v", code, body) + } + rotated := feedURL(t, body) + if rotated == aliceURL { + t.Error("rotate kept the token") + } + if rec := fetchICS(t, h, aliceURL); rec.Code != http.StatusNotFound { + t.Errorf("old URL after rotate = %d, want 404", rec.Code) + } + if rec := fetchICS(t, h, rotated); rec.Code != http.StatusOK { + t.Errorf("new URL = %d", rec.Code) + } + // Unknown and malformed tokens. + for _, path := range []string{"/_bm/calendar/" + strings.Repeat("0", 64) + ".ics", "/_bm/calendar/nope.ics"} { + if rec := fetchICS(t, h, path); rec.Code != http.StatusNotFound { + t.Errorf("%s = %d, want 404", path, rec.Code) + } + } + + // Deleting: 204, idempotent, the URL is gone. + if code, _ := alice.send("DELETE", "/_bm/api/v1/calendar-feed", ""); code != http.StatusNoContent { + t.Errorf("DELETE = %d", code) + } + if code, _ := alice.send("DELETE", "/_bm/api/v1/calendar-feed", ""); code != http.StatusNoContent { + t.Errorf("second DELETE = %d", code) + } + if rec := fetchICS(t, h, rotated); rec.Code != http.StatusNotFound { + t.Errorf("URL after delete = %d", rec.Code) + } + if body := alice.ok("GET", "/_bm/api/v1/calendar-feed"); body["data"] != nil { + t.Errorf("feed after delete = %v", body) + } + + // The feed goes with its user. + gdb.Exec(`DELETE FROM users WHERE email = ?`, "bob@example.test") + if n := count(t, gdb, "jz_bm_calendar_feeds"); n != 0 { + t.Errorf("feeds after deleting bob = %d", n) + } +} + +func TestCalendarFeedSchema(t *testing.T) { + _, _, gdb := migrated(t) + uid := createUser(t, gdb, "carol@example.test") + exec := func(db *gorm.DB, sql string, args ...any) error { return db.Exec(sql, args...).Error } + if err := exec(gdb, `INSERT INTO jz_bm_calendar_feeds (user_id, token) VALUES (?, 'a')`, uid); err != nil { + t.Fatal(err) + } + var feed models.CalendarFeed + if err := gdb.Where("user_id = ?", uid).Take(&feed).Error; err != nil { + t.Fatal(err) + } + if !feed.IncludeMeetings || !feed.IncludeDeadlines || feed.ExcludedCourseIDs == nil || len(feed.ExcludedCourseIDs) != 0 { + t.Errorf("defaults = %+v", feed) + } + other := createUser(t, gdb, "dave@example.test") + for _, bad := range []struct { + sql string + args []any + }{ + {`INSERT INTO jz_bm_calendar_feeds (user_id, token) VALUES (?, 'b')`, []any{uid}}, + {`INSERT INTO jz_bm_calendar_feeds (user_id, token) VALUES (?, 'a')`, []any{other}}, + {`INSERT INTO jz_bm_calendar_feeds (user_id, token) VALUES (999999, 'c')`, nil}, + {`INSERT INTO jz_bm_calendar_feeds (user_id, token, excluded_course_ids) VALUES (?, 'd', '{}')`, []any{other}}, + } { + if err := exec(gdb, bad.sql, bad.args...); err == nil { + t.Errorf("accepted: %s", bad.sql) + } + } +} diff --git a/classes/calendar_test.go b/classes/calendar_test.go new file mode 100644 index 0000000..a00fa11 --- /dev/null +++ b/classes/calendar_test.go @@ -0,0 +1,213 @@ +package classes_test + +import ( + "strings" + "testing" + "time" + + "git.golem15.com/jakub/sm-bm-plugin/classes" + "git.golem15.com/jakub/sm-bm-plugin/models" +) + +func sp(s string) *string { return &s } +func up(n uint) *uint { return &n } + +func calendarFixture() classes.CalendarData { + updated := time.Date(2026, 10, 2, 12, 30, 0, 0, time.UTC) + glob := &models.Course{ID: 1, Name: "Globalizacja i regionalizacja", Form: models.FormWyklad, Lecturer: sp("prof. dr hab. Piotr Mikiewicz")} + hist := &models.Course{ID: 8, Name: "Historia wojen i konfliktów do 1945 roku", Form: models.FormInne, ElectiveGroup: sp("historia-koncepcje")} + konc := &models.Course{ID: 9, Name: "Koncepcje wojny i pokoju – historia i współczesność", Form: models.FormInne, ElectiveGroup: sp("historia-koncepcje")} + m3 := &models.Meeting{ID: 3, Number: 3, StartsOn: "2026-10-24", EndsOn: "2026-10-25", Notes: sp("stacjonarny (Koszarowa 3, bud. 21)"), UpdatedAt: updated} + m2 := &models.Meeting{ID: 2, Number: 2, StartsOn: "2026-10-17", EndsOn: "2026-10-18", Notes: sp("online"), UpdatedAt: updated} + return classes.CalendarData{ + Sessions: []models.ClassSession{ + {ID: 11, CourseID: 1, Course: glob, MeetingID: up(3), Meeting: m3, StartsAt: "2026-10-24T08:00:00+02:00", EndsAt: "2026-10-24T09:30:00+02:00", Room: sp("237"), Notes: sp("prof. P. Mikiewicz"), UpdatedAt: updated}, + {ID: 12, CourseID: 8, Course: hist, MeetingID: up(3), Meeting: m3, StartsAt: "2026-10-25T08:00:00+01:00", EndsAt: "2026-10-25T09:30:00+01:00", Room: sp("110"), UpdatedAt: updated}, + {ID: 13, CourseID: 9, Course: konc, MeetingID: up(2), Meeting: m2, StartsAt: "2026-10-17T10:00:00+02:00", EndsAt: "2026-10-17T11:30:00+02:00", IsOnline: true, Room: sp("237"), UpdatedAt: updated}, + }, + Meetings: []models.Meeting{*m2, *m3}, + Deadlines: []models.Deadline{ + {ID: 21, Title: "Esej; wersja 1, szkic", DueAt: "2026-11-30T23:59:00+01:00", CourseID: up(1), Course: glob, Description: sp("Wysłać\nmailem"), UpdatedAt: updated}, + {ID: 22, Title: "Esej z koncepcji", DueAt: "2026-12-01T12:00:00+01:00", CourseID: up(9), Course: konc, UpdatedAt: updated}, + {ID: 23, Title: "Ślubowanie", DueAt: "2026-10-05T23:59:00+02:00", UpdatedAt: updated}, + }, + } +} + +// unfold returns the content lines of an ICS stream. +func unfold(t *testing.T, ics []byte) []string { + t.Helper() + s := string(ics) + if !strings.HasSuffix(s, "\r\n") { + t.Fatalf("stream does not end with CRLF") + } + for _, line := range strings.Split(strings.TrimSuffix(s, "\r\n"), "\r\n") { + if len(line) > 75 { + t.Errorf("line of %d octets: %q", len(line), line) + } + } + return strings.Split(strings.TrimSuffix(strings.ReplaceAll(s, "\r\n ", ""), "\r\n"), "\r\n") +} + +// events splits the content lines into VEVENTs keyed by UID. +func events(lines []string) map[string][]string { + out := map[string][]string{} + var cur []string + for _, l := range lines { + switch { + case l == "BEGIN:VEVENT": + cur = []string{} + case l == "END:VEVENT": + for _, c := range cur { + if uid, ok := strings.CutPrefix(c, "UID:"); ok { + out[uid] = cur + } + } + cur = nil + case cur != nil: + cur = append(cur, l) + } + } + return out +} + +func has(lines []string, want string) bool { + for _, l := range lines { + if l == want { + return true + } + } + return false +} + +func TestBuildCalendarHeaderAndBalance(t *testing.T) { + lines := unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{IncludeMeetings: true, IncludeDeadlines: true, SiteURL: "https://bm.jakubzych.com"})) + if lines[0] != "BEGIN:VCALENDAR" || lines[len(lines)-1] != "END:VCALENDAR" { + t.Errorf("first/last lines %q %q", lines[0], lines[len(lines)-1]) + } + for _, want := range []string{ + "PRODID:-//jakubzych//BM UWr//PL", "VERSION:2.0", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", + "X-WR-CALNAME:BM UWr – plan zajęć", "X-WR-TIMEZONE:Europe/Warsaw", + "REFRESH-INTERVAL;VALUE=DURATION:PT6H", "X-PUBLISHED-TTL:PT6H", + } { + if !has(lines, want) { + t.Errorf("missing %q", want) + } + } + depth := map[string]int{} + for _, l := range lines { + if c, ok := strings.CutPrefix(l, "BEGIN:"); ok { + depth[c]++ + } + if c, ok := strings.CutPrefix(l, "END:"); ok { + depth[c]-- + } + } + for c, d := range depth { + if d != 0 { + t.Errorf("BEGIN/END of %s off by %d", c, d) + } + } + if n := len(events(lines)); n != 3+2+3 { + t.Errorf("%d events, want 8", n) + } +} + +func TestBuildCalendarSessions(t *testing.T) { + ev := events(unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{SiteURL: "https://bm.jakubzych.com"}))) + glob := ev["session-11@bm.jakubzych.com"] + for _, want := range []string{ + "DTSTART:20261024T060000Z", "DTEND:20261024T073000Z", + "DTSTAMP:20261002T123000Z", "LAST-MODIFIED:20261002T123000Z", "SEQUENCE:0", + "SUMMARY:Globalizacja i regionalizacja – wykład", + `LOCATION:Koszarowa 3\, s. 237 (bud. 21 ISMiB)`, + `DESCRIPTION:Prowadzący: prof. dr hab. Piotr Mikiewicz\nZjazd 3\nprof. P. Mikiewicz\nhttps://bm.jakubzych.com/przedmioty/1`, + } { + if !has(glob, want) { + t.Errorf("session 11 lacks %q:\n%s", want, strings.Join(glob, "\n")) + } + } + // After the DST change Warsaw is UTC+1. + hist := ev["session-12@bm.jakubzych.com"] + if !has(hist, "DTSTART:20261025T070000Z") || !has(hist, "SUMMARY:Historia wojen i konfliktów do 1945 roku") { + t.Errorf("session 12:\n%s", strings.Join(hist, "\n")) + } + if !has(hist, `DESCRIPTION:Zjazd 3\nPrzedmiot do wyboru\nhttps://bm.jakubzych.com/przedmioty/8`) { + t.Errorf("session 12 description:\n%s", strings.Join(hist, "\n")) + } + online := ev["session-13@bm.jakubzych.com"] + if !has(online, "SUMMARY:Koncepcje wojny i pokoju – historia i współczesność (online)") || !has(online, "LOCATION:online") { + t.Errorf("online session:\n%s", strings.Join(online, "\n")) + } + if len(ev) != 3 { + t.Errorf("meetings or deadlines without being asked: %v", ev) + } +} + +func TestBuildCalendarExclusions(t *testing.T) { + ev := events(unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{ExcludedCourseIDs: []uint{9}, IncludeDeadlines: true}))) + if _, ok := ev["session-13@bm.jakubzych.com"]; ok { + t.Error("a session of an excluded course is listed") + } + if _, ok := ev["deadline-22@bm.jakubzych.com"]; ok { + t.Error("a deadline of an excluded course is listed") + } + for _, uid := range []string{"session-11@bm.jakubzych.com", "session-12@bm.jakubzych.com", "deadline-21@bm.jakubzych.com", "deadline-23@bm.jakubzych.com"} { + if _, ok := ev[uid]; !ok { + t.Errorf("%s is missing", uid) + } + } + // Without a site URL there is no link. + if has(ev["session-11@bm.jakubzych.com"], "URL:https://bm.jakubzych.com/przedmioty/1") { + t.Error("link without a site URL") + } +} + +func TestBuildCalendarMeetingsAreAllDay(t *testing.T) { + ev := events(unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{IncludeMeetings: true}))) + m3 := ev["meeting-3@bm.jakubzych.com"] + for _, want := range []string{ + "DTSTART;VALUE=DATE:20261024", "DTEND;VALUE=DATE:20261026", + "SUMMARY:3. zjazd (stacjonarny)", "TRANSP:TRANSPARENT", + `DESCRIPTION:stacjonarny (Koszarowa 3\, bud. 21)`, + } { + if !has(m3, want) { + t.Errorf("meeting 3 lacks %q:\n%s", want, strings.Join(m3, "\n")) + } + } + if !has(ev["meeting-2@bm.jakubzych.com"], "SUMMARY:2. zjazd (online)") { + t.Errorf("meeting 2: %v", ev["meeting-2@bm.jakubzych.com"]) + } +} + +func TestBuildCalendarDeadlines(t *testing.T) { + lines := unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{IncludeDeadlines: true})) + d := events(lines)["deadline-21@bm.jakubzych.com"] + for _, want := range []string{ + "DTSTART:20261130T222900Z", "DTEND:20261130T225900Z", + `SUMMARY:Termin: Esej\; wersja 1\, szkic`, + `DESCRIPTION:Przedmiot: Globalizacja i regionalizacja\nWysłać\nmailem`, + "BEGIN:VALARM", "ACTION:DISPLAY", "TRIGGER:-P1D", "END:VALARM", + } { + if !has(d, want) { + t.Errorf("deadline lacks %q:\n%s", want, strings.Join(d, "\n")) + } + } + if strings.Contains(strings.Join(lines, "\n"), "VALARM") && has(events(lines)["session-11@bm.jakubzych.com"], "BEGIN:VALARM") { + t.Error("a session has an alarm") + } +} + +func TestSummaries(t *testing.T) { + for form, want := range map[string]string{ + models.FormWyklad: "X – wykład", models.FormCwiczenia: "X – ćwiczenia", models.FormKonwersatorium: "X – konwersatorium", + models.FormSeminarium: "X – seminarium", models.FormInne: "X", + } { + if got := classes.SessionSummary(models.ClassSession{Course: &models.Course{Name: "X", Form: form}}); got != want { + t.Errorf("%s: %q, want %q", form, got, want) + } + } + if got := classes.MeetingSummary(models.Meeting{Number: 7}); got != "7. zjazd" { + t.Errorf("meeting without notes = %q", got) + } +} diff --git a/classes/ics_test.go b/classes/ics_test.go new file mode 100644 index 0000000..3b40a51 --- /dev/null +++ b/classes/ics_test.go @@ -0,0 +1,115 @@ +package classes_test + +import ( + "strings" + "testing" + "time" + "unicode/utf8" + + "git.golem15.com/jakub/sm-bm-plugin/classes" + "git.golem15.com/jakub/sm-bm-plugin/models" +) + +func TestEscapeText(t *testing.T) { + cases := map[string]string{ + "zwykły tekst": "zwykły tekst", + `a\b`: `a\\b`, + "a;b,c": `a\;b\,c`, + "pierwsza\ndruga": `pierwsza\ndruga`, + "windows\r\nkoniec\rcr": `windows\nkonieccr`, + `już \n nie jest znakiem`: `już \\n nie jest znakiem`, + } + for in, want := range cases { + if got := classes.EscapeText(in); got != want { + t.Errorf("EscapeText(%q) = %q, want %q", in, got, want) + } + } +} + +// checkFolded checks that a folded line has physical lines of at most 75 +// octets, each valid UTF-8, continuation lines starting with a space, and +// that unfolding gives the input back. +func checkFolded(t *testing.T, in, folded string) { + t.Helper() + parts := strings.Split(folded, "\r\n") + for i, p := range parts { + if len(p) > 75 { + t.Errorf("physical line %d has %d octets: %q", i, len(p), p) + } + if !utf8.ValidString(p) { + t.Errorf("physical line %d splits a UTF-8 sequence: %q", i, p) + } + if i > 0 && !strings.HasPrefix(p, " ") { + t.Errorf("continuation line %d does not start with a space: %q", i, p) + } + } + if unfolded := strings.ReplaceAll(folded, "\r\n ", ""); unfolded != in { + t.Errorf("unfolded = %q, want %q", unfolded, in) + } +} + +func TestFoldLine(t *testing.T) { + short := "SUMMARY:" + strings.Repeat("a", 67) // exactly 75 octets + if got := classes.FoldLine(short); got != short { + t.Errorf("a 75-octet line was folded: %q", got) + } + long := "SUMMARY:" + strings.Repeat("a", 68) + got := classes.FoldLine(long) + if got != "SUMMARY:"+strings.Repeat("a", 67)+"\r\n a" { + t.Errorf("76-octet line folded as %q", got) + } + checkFolded(t, long, got) + + // Two-octet Polish letters at every offset, so a boundary falls inside a + // character at some point; and a three-octet en dash. + for pad := 0; pad < 4; pad++ { + in := "DESCRIPTION:" + strings.Repeat("x", pad) + strings.Repeat("zażółć gęślą jaźń – ", 12) + checkFolded(t, in, classes.FoldLine(in)) + } + // Four-octet characters. + emoji := "SUMMARY:" + strings.Repeat("😀", 50) + checkFolded(t, emoji, classes.FoldLine(emoji)) +} + +func TestICSWriterUsesCRLFAndFolds(t *testing.T) { + var w classes.ICS + w.Begin("VCALENDAR") + w.Text("X-WR-CALNAME", "Bezpieczeństwo międzynarodowe; plan, zjazdy\nwiele linii "+strings.Repeat("ąę", 40)) + w.UTC("DTSTART", time.Date(2026, 10, 3, 8, 0, 0, 0, models.Location)) + w.Date("DTSTART", time.Date(2026, 10, 24, 0, 0, 0, 0, models.Location)) + w.End("VCALENDAR") + out := string(w.Bytes()) + if !strings.HasSuffix(out, "END:VCALENDAR\r\n") { + t.Errorf("does not end with END:VCALENDAR CRLF: %q", out) + } + if strings.Contains(strings.ReplaceAll(out, "\r\n", ""), "\n") || strings.Contains(strings.ReplaceAll(out, "\r\n", ""), "\r") { + t.Errorf("bare LF or CR in %q", out) + } + for _, line := range strings.Split(strings.TrimSuffix(out, "\r\n"), "\r\n") { + if len(line) > 75 { + t.Errorf("line of %d octets: %q", len(line), line) + } + } + unfolded := strings.ReplaceAll(out, "\r\n ", "") + for _, want := range []string{ + `X-WR-CALNAME:Bezpieczeństwo międzynarodowe\; plan\, zjazdy\nwiele linii ąę`, + "DTSTART:20261003T060000Z\r\n", + "DTSTART;VALUE=DATE:20261024\r\n", + } { + if !strings.Contains(unfolded, want) { + t.Errorf("output lacks %q:\n%s", want, unfolded) + } + } +} + +func TestFormatUTC(t *testing.T) { + // Saturday before the DST change (+02:00), Sunday after it (+01:00). + sat := time.Date(2026, 10, 24, 8, 0, 0, 0, models.Location) + sun := time.Date(2026, 10, 25, 8, 0, 0, 0, models.Location) + if got := classes.FormatUTC(sat); got != "20261024T060000Z" { + t.Errorf("Saturday = %s", got) + } + if got := classes.FormatUTC(sun); got != "20261025T070000Z" { + t.Errorf("Sunday = %s", got) + } +} diff --git a/controllers/api/handlers_test.go b/controllers/api/handlers_test.go index 4e69ed4..235eb96 100644 --- a/controllers/api/handlers_test.go +++ b/controllers/api/handlers_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "git.golem15.com/golem15/summercms/modules/bouncer" @@ -48,6 +49,10 @@ func handlers(h *api.Handlers) map[string]http.HandlerFunc { "unmark": h.UnmarkRead, "materials": h.Materials, "material": h.Material, + "feed": h.CalendarFeed, + "feed-put": h.UpdateCalendarFeed, + "rotate": h.RotateCalendarFeed, + "feed-del": h.DeleteCalendarFeed, } } @@ -142,3 +147,33 @@ func TestValidQueriesReachTheDatabase(t *testing.T) { } } } + +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()) + } + } +} diff --git a/plugin_test.go b/plugin_test.go index 6fc2613..723e1db 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -73,11 +73,11 @@ func TestPluginActivates(t *testing.T) { if p.ID() != "jz.bm" || !slices.Equal(p.Requires(), []string{"golem15.user"}) { t.Errorf("ID %q Requires %v", p.ID(), p.Requires()) } - if n := len(p.Models()); n != 9 { - t.Errorf("Models = %d, want 9", n) + if n := len(p.Models()); n != 10 { + t.Errorf("Models = %d, want 10", n) } - if n := len(p.Migrations()); n != 10 { - t.Errorf("Migrations = %d, want 10", n) + if n := len(p.Migrations()); n != 11 { + t.Errorf("Migrations = %d, want 11", n) } if !slices.ContainsFunc(p.Commands(), func(c bonfire.Command) bool { return c.Name == "bm:seed" }) { t.Error("bm:seed is not registered") @@ -88,6 +88,9 @@ func TestPluginActivates(t *testing.T) { if got := app.Config.Int("jz.bm.dashboard_limit"); got != 5 { t.Errorf("jz.bm.dashboard_limit = %d, want the default 5", got) } + if got := app.Config.String("jz.bm.public_url"); got != "" { + t.Errorf("jz.bm.public_url = %q, want the default empty", got) + } } func TestRoutes(t *testing.T) { @@ -109,8 +112,13 @@ func TestRoutes(t *testing.T) { "DELETE /_bm/api/v1/readings/{id}/progress", "GET /_bm/api/v1/materials", "GET /_bm/api/v1/materials/{id}", + "GET /_bm/api/v1/calendar-feed", + "PUT /_bm/api/v1/calendar-feed", + "POST /_bm/api/v1/calendar-feed/rotate", + "DELETE /_bm/api/v1/calendar-feed", } var got []string + sawFeed := false for _, rt := range router.Routes() { if rt.PluginID != "jz.bm" { continue @@ -122,11 +130,24 @@ func TestRoutes(t *testing.T) { } continue } + if rt.Method+" "+rt.Pattern == "GET /_bm/calendar/{token}" { + // The calendar feed: public (the token is the credential) but + // throttled per IP and per token. + want := []string{"throttle:jz.bm.calendar-ip", "throttle:jz.bm.calendar-token"} + if !slices.Equal(rt.Middleware, want) { + t.Errorf("the calendar feed has middleware %v, want %v", rt.Middleware, want) + } + sawFeed = true + continue + } got = append(got, rt.Method+" "+rt.Pattern) if !slices.Contains(rt.Middleware, "jwt.auth") { t.Errorf("%s %s is not behind jwt.auth: %v", rt.Method, rt.Pattern, rt.Middleware) } } + if !sawFeed { + t.Error("GET /_bm/calendar/{token} is not registered") + } if !slices.Equal(got, want) { t.Errorf("routes =\n%v\nwant\n%v", got, want) } diff --git a/postgres_test.go b/postgres_test.go index 04f3058..f1b1dec 100644 --- a/postgres_test.go +++ b/postgres_test.go @@ -155,7 +155,7 @@ func migrated(t *testing.T) (*backpack.App, []party.Plugin, *gorm.DB) { var bmTables = []string{ "jz_bm_meetings", "jz_bm_courses", "jz_bm_class_sessions", "jz_bm_deadlines", "jz_bm_readings", - "jz_bm_reading_progress", "jz_bm_tags", "jz_bm_materials", "jz_bm_material_tag", + "jz_bm_reading_progress", "jz_bm_tags", "jz_bm_materials", "jz_bm_material_tag", "jz_bm_calendar_feeds", } func TestMigrateUpAndRollback(t *testing.T) {