277 lines
10 KiB
Go
277 lines
10 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|