Files
sm-bm-plugin/calendar_postgres_test.go

277 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}