package calendartest import ( "context" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // A stranger's whole journey, through the router, with no session and // no script (booking spec §5). // publishedPage is a host with a handle and a published every-day 9–5 // page, seen through a picky mailer so the tests can read what went out. func publishedPage(t *testing.T) (*calendar.App, *pickyMailer, string, calendar.Person) { t.Helper() return publishedPageTitled(t, "Quick chat", "Half an hour.", "every day 9-5") } func publishedPageTitled(t *testing.T, title, desc, avail string) (*calendar.App, *pickyMailer, string, calendar.Person) { t.Helper() mailer := &pickyMailer{} app := newAppWithMailer(t, mailer) session := app.SignInForTest(t, "host@example.com") h := app.Handler() postAs(t, h, session, "/settings", url.Values{"zone": {"UTC"}, "week_start": {"1"}, "default_reminder_min": {"10"}, "default_all_day_min": {"0"}, "handle": {"paul"}}) rec := postAs(t, h, session, "/settings/booking", url.Values{"title": {title}, "description": {desc}, "availability": {avail}, "duration_min": {"30"}, "buffer_min": {"0"}, "notice_min": {"0"}, "horizon_days": {"30"}}) if rec.Code != http.StatusSeeOther { t.Fatalf("create page: %d\n%s", rec.Code, rec.Body.String()) } postAs(t, h, session, "/settings/booking/1/publish", url.Values{"publish": {"1"}}) body := getAs(t, h, session, "/settings/booking").Body.String() m := regexp.MustCompile(`/book/paul/[a-z0-9]{8}`).FindString(body) if m == "" { t.Fatalf("no public URL on the list:\n%s", body) } mailer.reset() host := app.PersonByAddressForTest(t, "host@example.com") return app, mailer, m, host } func strangerGet(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, calendar.RequestForTest(nil, http.MethodGet, target, nil)) return rec } func strangerPost(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, calendar.RequestForTest(nil, http.MethodPost, target, form)) return rec } var ( reStart = regexp.MustCompile(`name="start" value="(\d+)"`) reManage = regexp.MustCompile(`/book/m/([0-9a-f]{64})`) reDay = regexp.MustCompile(`\?day=(\d{4}-\d{2}-\d{2})`) ) // futureDaySlots uses a whole future day so a late test run still has // enough slots for the hold-limit and rescheduling tests. func futureDaySlots(t *testing.T, h http.Handler, path string) (string, []string) { t.Helper() page := strangerGet(t, h, path) if page.Code != 200 { t.Fatalf("page: %d\n%s", page.Code, page.Body.String()) } today := time.Now().UTC().Format("2006-01-02") selected := "" for _, match := range reDay.FindAllStringSubmatch(page.Body.String(), -1) { if match[1] > today { selected = match[1] break } } if selected == "" { t.Fatalf("no future day links:\n%s", page.Body.String()) } dayPath := path + "?day=" + selected + "&zone=UTC" day := strangerGet(t, h, dayPath) var starts []string for _, s := range reStart.FindAllStringSubmatch(day.Body.String(), -1) { starts = append(starts, s[1]) } if len(starts) == 0 { t.Fatalf("no slots on the day page:\n%s", day.Body.String()) } return dayPath, starts } func holdForm(start, name, address string) url.Values { return url.Values{"start": {start}, "zone": {"UTC"}, "name": {name}, "address": {address}, "note": {"About the thing"}} } func TestAStrangerHoldsAndOnlyTheButtonConfirms(t *testing.T) { app, mailer, path, _ := publishedPage(t) h := app.Handler() _, starts := futureDaySlots(t, h, path) // Step one: the slot pick, no name yet. pick := strangerPost(t, h, path, url.Values{"start": {starts[0]}, "zone": {"UTC"}}) if pick.Code != 200 || !strings.Contains(pick.Body.String(), `name="name"`) { t.Fatalf("slot pick: %d\n%s", pick.Code, pick.Body.String()) } // Step two: the hold. rec := strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io")) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "doc@tito.io") { t.Fatalf("hold: %d\n%s", rec.Code, rec.Body.String()) } if n := mailer.count(); n != 1 || mailer.to(0) != "doc@tito.io" { t.Fatalf("mails after hold = %d to %q, want one to the booker", n, mailer.to(0)) } link := reManage.FindString(mailer.body(0)) if link == "" { t.Fatalf("no manage link in:\n%s", mailer.body(0)) } // The link's GET changes nothing. page := strangerGet(t, h, link) if page.Code != 200 || !strings.Contains(page.Body.String(), "Confirm this booking") { t.Fatalf("link GET: %d\n%s", page.Code, page.Body.String()) } if mailer.count() != 1 { t.Error("a GET of the link mailed somebody") } // The button confirms, once. man := strangerPost(t, h, link+"/confirm", nil) if man.Code != 200 || !strings.Contains(man.Body.String(), "Reschedule") { t.Fatalf("confirm: %d\n%s", man.Code, man.Body.String()) } if mailer.count() != 2 || mailer.to(1) != "host@example.com" || !strings.Contains(mailer.body(1), "Doc") { t.Errorf("host mail after confirm: count %d to %q", mailer.count(), mailer.to(1)) } strangerPost(t, h, link+"/confirm", nil) if mailer.count() != 2 { t.Error("confirming twice mailed the host twice") } if page.Header().Get("Cache-Control") != "no-store" || page.Header().Get("Referrer-Policy") != "no-referrer" { t.Error("public responses are missing no-store / no-referrer") } } func TestTheICSIsARequestWithNoManagementLinkAndNoHostPrivateFields(t *testing.T) { app, mailer, path, host := publishedPage(t) h := app.Handler() st := app.StoreForTest() ctx := context.Background() _, starts := futureDaySlots(t, h, path) strangerPost(t, h, path, holdForm(starts[0], "Doe, Jane \"JJ\": Support", "doc@tito.io")) link := reManage.FindString(mailer.body(0)) strangerPost(t, h, link+"/confirm", nil) // The host edits everything private. v, _ := st.BookingByToken(ctx, strings.TrimPrefix(link, "/book/m/")) ev, _ := st.Event(ctx, host.ID, v.EventID) in := calendar.EventInput{CalendarID: ev.CalendarID, Title: ev.Title, Location: "Room 9", Notes: "private", AllDay: ev.AllDay, StartLocal: ev.StartLocal, EndLocal: ev.EndLocal, TZ: ev.TZ, Kind: ev.Kind, Status: ev.Status} if err := st.UpdateEvent(ctx, host.ID, v.EventID, in); err != nil { t.Fatal(err) } st.SetAttendees(ctx, v.EventID, []string{"doc@tito.io", "boss@example.com"}) ics := strangerGet(t, h, link+"/calendar.ics") body := ics.Body.String() for _, want := range []string{"METHOD:REQUEST", "SUMMARY:Quick chat", "ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:doc@tito.io", "ORGANIZER:mailto:host@example.com", "SEQUENCE:2", "STATUS:CONFIRMED", "UID:"} { if !strings.Contains(body, want) { t.Errorf("ics lacks %q:\n%s", want, body) } } for _, never := range []string{"URL:", "CN=", "Room 9", "private", "boss@example.com", "LOCATION"} { if strings.Contains(body, never) { t.Errorf("ics carries %q:\n%s", never, body) } } if ct := ics.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/calendar") { t.Errorf("content-type %q", ct) } if events, _, err := calendar.ParseICS(strings.NewReader(body), "UTC"); err != nil || len(events) != 1 { t.Errorf("our own parser: %d events, %v", len(events), err) } man := strangerGet(t, h, link).Body.String() for _, never := range []string{"Room 9", "private", "boss@example.com"} { if strings.Contains(man, never) { t.Errorf("the manage page shows the host's private %q", never) } } // A held booking has no ICS. strangerPost(t, h, path, holdForm(starts[1], "B", "b@example.com")) held := reManage.FindString(mailer.body(mailer.count() - 1)) if rec := strangerGet(t, h, held+"/calendar.ics"); rec.Code != http.StatusNotFound { t.Errorf("ics for a hold: %d, want 404", rec.Code) } } func TestTheSameSlotCannotBeHeldTwiceAndAWrongTokenIsTheRouters404(t *testing.T) { app, _, path, _ := publishedPage(t) h := app.Handler() _, starts := futureDaySlots(t, h, path) strangerPost(t, h, path, holdForm(starts[0], "A", "a@example.com")) rec := strangerPost(t, h, path, holdForm(starts[0], "B", "b@example.com")) if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "just gone") { t.Errorf("second hold: %d", rec.Code) } a := strangerGet(t, h, "/book/m/"+strings.Repeat("0", 64)) b := strangerGet(t, h, "/no/such/route") if a.Code != 404 || a.Body.String() != b.Body.String() { t.Errorf("token 404 (%d) differs from route 404 (%d)", a.Code, b.Code) } if rec := strangerGet(t, h, "/book/nobody/zzzzzzzz"); rec.Code != 404 || rec.Body.String() != b.Body.String() { t.Error("an unknown handle is not the router's 404") } } func TestRescheduleNeedsTheRevisionAndCancelTellsBoth(t *testing.T) { app, mailer, path, _ := publishedPage(t) h := app.Handler() _, starts := futureDaySlots(t, h, path) strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io")) link := reManage.FindString(mailer.body(0)) strangerPost(t, h, link+"/confirm", nil) mailer.reset() picker := strangerGet(t, h, link+"/reschedule?day="+time.Now().UTC().AddDate(0, 0, 1).Format("2006-01-02")+"&zone=UTC") if picker.Code != 200 { t.Fatalf("picker: %d\n%s", picker.Code, picker.Body.String()) } rev := regexp.MustCompile(`name="rev" value="(\d+)"`).FindStringSubmatch(picker.Body.String()) next := reStart.FindAllStringSubmatch(picker.Body.String(), -1) if rev == nil || len(next) < 2 { t.Fatalf("picker lacks rev or slots:\n%s", picker.Body.String()) } form := url.Values{"start": {next[1][1]}, "zone": {"UTC"}, "rev": {rev[1]}} rec := strangerPost(t, h, link+"/reschedule", form) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Reschedule") { t.Fatalf("reschedule: %d\n%s", rec.Code, rec.Body.String()) } if mailer.count() != 2 { t.Errorf("reschedule mailed %d, want booker and host", mailer.count()) } if !strings.Contains(strangerGet(t, h, link+"/calendar.ics").Body.String(), "SEQUENCE:2") { t.Error("SEQUENCE not bumped by the move") } // The same form again: stale rev, nothing happens. mailer.reset() strangerPost(t, h, link+"/reschedule", form) if mailer.count() != 0 { t.Error("a resubmitted reschedule form sent mail") } rec = strangerPost(t, h, link+"/cancel", nil) if rec.Code != 200 || mailer.count() != 2 { t.Errorf("cancel: %d, mails %d", rec.Code, mailer.count()) } if !strings.Contains(strangerGet(t, h, link+"/calendar.ics").Body.String(), "METHOD:CANCEL") { t.Error("no METHOD:CANCEL after cancel") } mailer.reset() strangerPost(t, h, link+"/cancel", nil) if mailer.count() != 0 { t.Error("cancelling twice mailed again") } } func TestAHoldWhoseMailIsRefusedStillStands(t *testing.T) { app, mailer, path, _ := publishedPage(t) mailer.refuse = "bounce@example.com" h := app.Handler() _, starts := futureDaySlots(t, h, path) rec := strangerPost(t, h, path, holdForm(starts[0], "B", "bounce@example.com")) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "If nothing arrives") { t.Errorf("refused mail: %d\n%s", rec.Code, rec.Body.String()) } // The slot is still held. if again := strangerPost(t, h, path, holdForm(starts[0], "C", "c@example.com")); again.Code != http.StatusConflict { t.Errorf("the slot was released: %d", again.Code) } }