| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "net/url" |
| 8 | "regexp" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | calendar "amadan.net/oficina/calendar/internal/calendar" |
| 14 | ) |
| 15 | |
| 16 | // A stranger's whole journey, through the router, with no session and |
| 17 | // no script (booking spec §5). |
| 18 | |
| 19 | // publishedPage is a host with a handle and a published every-day 9–5 |
| 20 | // page, seen through a picky mailer so the tests can read what went out. |
| 21 | func publishedPage(t *testing.T) (*calendar.App, *pickyMailer, string, calendar.Person) { |
| 22 | t.Helper() |
| 23 | return publishedPageTitled(t, "Quick chat", "Half an hour.", "every day 9-5") |
| 24 | } |
| 25 | |
| 26 | func publishedPageTitled(t *testing.T, title, desc, avail string) (*calendar.App, *pickyMailer, string, calendar.Person) { |
| 27 | t.Helper() |
| 28 | mailer := &pickyMailer{} |
| 29 | app := newAppWithMailer(t, mailer) |
| 30 | session := app.SignInForTest(t, "host@example.com") |
| 31 | h := app.Handler() |
| 32 | postAs(t, h, session, "/settings", url.Values{"zone": {"UTC"}, "week_start": {"1"}, |
| 33 | "default_reminder_min": {"10"}, "default_all_day_min": {"0"}, "handle": {"paul"}}) |
| 34 | rec := postAs(t, h, session, "/settings/booking", url.Values{"title": {title}, "description": {desc}, |
| 35 | "availability": {avail}, "duration_min": {"30"}, "buffer_min": {"0"}, "notice_min": {"0"}, "horizon_days": {"30"}}) |
| 36 | if rec.Code != http.StatusSeeOther { |
| 37 | t.Fatalf("create page: %d\n%s", rec.Code, rec.Body.String()) |
| 38 | } |
| 39 | postAs(t, h, session, "/settings/booking/1/publish", url.Values{"publish": {"1"}}) |
| 40 | body := getAs(t, h, session, "/settings/booking").Body.String() |
| 41 | m := regexp.MustCompile(`/book/paul/[a-z0-9]{8}`).FindString(body) |
| 42 | if m == "" { |
| 43 | t.Fatalf("no public URL on the list:\n%s", body) |
| 44 | } |
| 45 | mailer.reset() |
| 46 | host := app.PersonByAddressForTest(t, "host@example.com") |
| 47 | return app, mailer, m, host |
| 48 | } |
| 49 | |
| 50 | func strangerGet(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { |
| 51 | t.Helper() |
| 52 | rec := httptest.NewRecorder() |
| 53 | h.ServeHTTP(rec, calendar.RequestForTest(nil, http.MethodGet, target, nil)) |
| 54 | return rec |
| 55 | } |
| 56 | |
| 57 | func strangerPost(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder { |
| 58 | t.Helper() |
| 59 | rec := httptest.NewRecorder() |
| 60 | h.ServeHTTP(rec, calendar.RequestForTest(nil, http.MethodPost, target, form)) |
| 61 | return rec |
| 62 | } |
| 63 | |
| 64 | var ( |
| 65 | reStart = regexp.MustCompile(`name="start" value="(\d+)"`) |
| 66 | reManage = regexp.MustCompile(`/book/m/([0-9a-f]{64})`) |
| 67 | reDay = regexp.MustCompile(`\?day=(\d{4}-\d{2}-\d{2})`) |
| 68 | ) |
| 69 | |
| 70 | // futureDaySlots uses a whole future day so a late test run still has |
| 71 | // enough slots for the hold-limit and rescheduling tests. |
| 72 | func futureDaySlots(t *testing.T, h http.Handler, path string) (string, []string) { |
| 73 | t.Helper() |
| 74 | page := strangerGet(t, h, path) |
| 75 | if page.Code != 200 { |
| 76 | t.Fatalf("page: %d\n%s", page.Code, page.Body.String()) |
| 77 | } |
| 78 | today := time.Now().UTC().Format("2006-01-02") |
| 79 | selected := "" |
| 80 | for _, match := range reDay.FindAllStringSubmatch(page.Body.String(), -1) { |
| 81 | if match[1] > today { |
| 82 | selected = match[1] |
| 83 | break |
| 84 | } |
| 85 | } |
| 86 | if selected == "" { |
| 87 | t.Fatalf("no future day links:\n%s", page.Body.String()) |
| 88 | } |
| 89 | dayPath := path + "?day=" + selected + "&zone=UTC" |
| 90 | day := strangerGet(t, h, dayPath) |
| 91 | var starts []string |
| 92 | for _, s := range reStart.FindAllStringSubmatch(day.Body.String(), -1) { |
| 93 | starts = append(starts, s[1]) |
| 94 | } |
| 95 | if len(starts) == 0 { |
| 96 | t.Fatalf("no slots on the day page:\n%s", day.Body.String()) |
| 97 | } |
| 98 | return dayPath, starts |
| 99 | } |
| 100 | |
| 101 | func holdForm(start, name, address string) url.Values { |
| 102 | return url.Values{"start": {start}, "zone": {"UTC"}, "name": {name}, "address": {address}, "note": {"About the thing"}} |
| 103 | } |
| 104 | |
| 105 | func TestAStrangerHoldsAndOnlyTheButtonConfirms(t *testing.T) { |
| 106 | app, mailer, path, _ := publishedPage(t) |
| 107 | h := app.Handler() |
| 108 | _, starts := futureDaySlots(t, h, path) |
| 109 | |
| 110 | // Step one: the slot pick, no name yet. |
| 111 | pick := strangerPost(t, h, path, url.Values{"start": {starts[0]}, "zone": {"UTC"}}) |
| 112 | if pick.Code != 200 || !strings.Contains(pick.Body.String(), `name="name"`) { |
| 113 | t.Fatalf("slot pick: %d\n%s", pick.Code, pick.Body.String()) |
| 114 | } |
| 115 | // Step two: the hold. |
| 116 | rec := strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io")) |
| 117 | if rec.Code != 200 || !strings.Contains(rec.Body.String(), "doc@tito.io") { |
| 118 | t.Fatalf("hold: %d\n%s", rec.Code, rec.Body.String()) |
| 119 | } |
| 120 | if n := mailer.count(); n != 1 || mailer.to(0) != "doc@tito.io" { |
| 121 | t.Fatalf("mails after hold = %d to %q, want one to the booker", n, mailer.to(0)) |
| 122 | } |
| 123 | link := reManage.FindString(mailer.body(0)) |
| 124 | if link == "" { |
| 125 | t.Fatalf("no manage link in:\n%s", mailer.body(0)) |
| 126 | } |
| 127 | // The link's GET changes nothing. |
| 128 | page := strangerGet(t, h, link) |
| 129 | if page.Code != 200 || !strings.Contains(page.Body.String(), "Confirm this booking") { |
| 130 | t.Fatalf("link GET: %d\n%s", page.Code, page.Body.String()) |
| 131 | } |
| 132 | if mailer.count() != 1 { |
| 133 | t.Error("a GET of the link mailed somebody") |
| 134 | } |
| 135 | // The button confirms, once. |
| 136 | man := strangerPost(t, h, link+"/confirm", nil) |
| 137 | if man.Code != 200 || !strings.Contains(man.Body.String(), "Reschedule") { |
| 138 | t.Fatalf("confirm: %d\n%s", man.Code, man.Body.String()) |
| 139 | } |
| 140 | if mailer.count() != 2 || mailer.to(1) != "host@example.com" || !strings.Contains(mailer.body(1), "Doc") { |
| 141 | t.Errorf("host mail after confirm: count %d to %q", mailer.count(), mailer.to(1)) |
| 142 | } |
| 143 | strangerPost(t, h, link+"/confirm", nil) |
| 144 | if mailer.count() != 2 { |
| 145 | t.Error("confirming twice mailed the host twice") |
| 146 | } |
| 147 | if page.Header().Get("Cache-Control") != "no-store" || page.Header().Get("Referrer-Policy") != "no-referrer" { |
| 148 | t.Error("public responses are missing no-store / no-referrer") |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func TestTheICSIsARequestWithNoManagementLinkAndNoHostPrivateFields(t *testing.T) { |
| 153 | app, mailer, path, host := publishedPage(t) |
| 154 | h := app.Handler() |
| 155 | st := app.StoreForTest() |
| 156 | ctx := context.Background() |
| 157 | _, starts := futureDaySlots(t, h, path) |
| 158 | strangerPost(t, h, path, holdForm(starts[0], "Doe, Jane \"JJ\": Support", "doc@tito.io")) |
| 159 | link := reManage.FindString(mailer.body(0)) |
| 160 | strangerPost(t, h, link+"/confirm", nil) |
| 161 | |
| 162 | // The host edits everything private. |
| 163 | v, _ := st.BookingByToken(ctx, strings.TrimPrefix(link, "/book/m/")) |
| 164 | ev, _ := st.Event(ctx, host.ID, v.EventID) |
| 165 | in := calendar.EventInput{CalendarID: ev.CalendarID, Title: ev.Title, Location: "Room 9", Notes: "private", |
| 166 | AllDay: ev.AllDay, StartLocal: ev.StartLocal, EndLocal: ev.EndLocal, TZ: ev.TZ, Kind: ev.Kind, Status: ev.Status} |
| 167 | if err := st.UpdateEvent(ctx, host.ID, v.EventID, in); err != nil { |
| 168 | t.Fatal(err) |
| 169 | } |
| 170 | st.SetAttendees(ctx, v.EventID, []string{"doc@tito.io", "boss@example.com"}) |
| 171 | |
| 172 | ics := strangerGet(t, h, link+"/calendar.ics") |
| 173 | body := ics.Body.String() |
| 174 | for _, want := range []string{"METHOD:REQUEST", "SUMMARY:Quick chat", "ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:doc@tito.io", |
| 175 | "ORGANIZER:mailto:host@example.com", "SEQUENCE:2", "STATUS:CONFIRMED", "UID:"} { |
| 176 | if !strings.Contains(body, want) { |
| 177 | t.Errorf("ics lacks %q:\n%s", want, body) |
| 178 | } |
| 179 | } |
| 180 | for _, never := range []string{"URL:", "CN=", "Room 9", "private", "boss@example.com", "LOCATION"} { |
| 181 | if strings.Contains(body, never) { |
| 182 | t.Errorf("ics carries %q:\n%s", never, body) |
| 183 | } |
| 184 | } |
| 185 | if ct := ics.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/calendar") { |
| 186 | t.Errorf("content-type %q", ct) |
| 187 | } |
| 188 | if events, _, err := calendar.ParseICS(strings.NewReader(body), "UTC"); err != nil || len(events) != 1 { |
| 189 | t.Errorf("our own parser: %d events, %v", len(events), err) |
| 190 | } |
| 191 | man := strangerGet(t, h, link).Body.String() |
| 192 | for _, never := range []string{"Room 9", "private", "boss@example.com"} { |
| 193 | if strings.Contains(man, never) { |
| 194 | t.Errorf("the manage page shows the host's private %q", never) |
| 195 | } |
| 196 | } |
| 197 | // A held booking has no ICS. |
| 198 | strangerPost(t, h, path, holdForm(starts[1], "B", "b@example.com")) |
| 199 | held := reManage.FindString(mailer.body(mailer.count() - 1)) |
| 200 | if rec := strangerGet(t, h, held+"/calendar.ics"); rec.Code != http.StatusNotFound { |
| 201 | t.Errorf("ics for a hold: %d, want 404", rec.Code) |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | func TestTheSameSlotCannotBeHeldTwiceAndAWrongTokenIsTheRouters404(t *testing.T) { |
| 206 | app, _, path, _ := publishedPage(t) |
| 207 | h := app.Handler() |
| 208 | _, starts := futureDaySlots(t, h, path) |
| 209 | strangerPost(t, h, path, holdForm(starts[0], "A", "a@example.com")) |
| 210 | rec := strangerPost(t, h, path, holdForm(starts[0], "B", "b@example.com")) |
| 211 | if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "just gone") { |
| 212 | t.Errorf("second hold: %d", rec.Code) |
| 213 | } |
| 214 | a := strangerGet(t, h, "/book/m/"+strings.Repeat("0", 64)) |
| 215 | b := strangerGet(t, h, "/no/such/route") |
| 216 | if a.Code != 404 || a.Body.String() != b.Body.String() { |
| 217 | t.Errorf("token 404 (%d) differs from route 404 (%d)", a.Code, b.Code) |
| 218 | } |
| 219 | if rec := strangerGet(t, h, "/book/nobody/zzzzzzzz"); rec.Code != 404 || rec.Body.String() != b.Body.String() { |
| 220 | t.Error("an unknown handle is not the router's 404") |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | func TestRescheduleNeedsTheRevisionAndCancelTellsBoth(t *testing.T) { |
| 225 | app, mailer, path, _ := publishedPage(t) |
| 226 | h := app.Handler() |
| 227 | _, starts := futureDaySlots(t, h, path) |
| 228 | strangerPost(t, h, path, holdForm(starts[0], "Doc", "doc@tito.io")) |
| 229 | link := reManage.FindString(mailer.body(0)) |
| 230 | strangerPost(t, h, link+"/confirm", nil) |
| 231 | mailer.reset() |
| 232 | |
| 233 | picker := strangerGet(t, h, link+"/reschedule?day="+time.Now().UTC().AddDate(0, 0, 1).Format("2006-01-02")+"&zone=UTC") |
| 234 | if picker.Code != 200 { |
| 235 | t.Fatalf("picker: %d\n%s", picker.Code, picker.Body.String()) |
| 236 | } |
| 237 | rev := regexp.MustCompile(`name="rev" value="(\d+)"`).FindStringSubmatch(picker.Body.String()) |
| 238 | next := reStart.FindAllStringSubmatch(picker.Body.String(), -1) |
| 239 | if rev == nil || len(next) < 2 { |
| 240 | t.Fatalf("picker lacks rev or slots:\n%s", picker.Body.String()) |
| 241 | } |
| 242 | form := url.Values{"start": {next[1][1]}, "zone": {"UTC"}, "rev": {rev[1]}} |
| 243 | rec := strangerPost(t, h, link+"/reschedule", form) |
| 244 | if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Reschedule") { |
| 245 | t.Fatalf("reschedule: %d\n%s", rec.Code, rec.Body.String()) |
| 246 | } |
| 247 | if mailer.count() != 2 { |
| 248 | t.Errorf("reschedule mailed %d, want booker and host", mailer.count()) |
| 249 | } |
| 250 | if !strings.Contains(strangerGet(t, h, link+"/calendar.ics").Body.String(), "SEQUENCE:2") { |
| 251 | t.Error("SEQUENCE not bumped by the move") |
| 252 | } |
| 253 | // The same form again: stale rev, nothing happens. |
| 254 | mailer.reset() |
| 255 | strangerPost(t, h, link+"/reschedule", form) |
| 256 | if mailer.count() != 0 { |
| 257 | t.Error("a resubmitted reschedule form sent mail") |
| 258 | } |
| 259 | rec = strangerPost(t, h, link+"/cancel", nil) |
| 260 | if rec.Code != 200 || mailer.count() != 2 { |
| 261 | t.Errorf("cancel: %d, mails %d", rec.Code, mailer.count()) |
| 262 | } |
| 263 | if !strings.Contains(strangerGet(t, h, link+"/calendar.ics").Body.String(), "METHOD:CANCEL") { |
| 264 | t.Error("no METHOD:CANCEL after cancel") |
| 265 | } |
| 266 | mailer.reset() |
| 267 | strangerPost(t, h, link+"/cancel", nil) |
| 268 | if mailer.count() != 0 { |
| 269 | t.Error("cancelling twice mailed again") |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | func TestAHoldWhoseMailIsRefusedStillStands(t *testing.T) { |
| 274 | app, mailer, path, _ := publishedPage(t) |
| 275 | mailer.refuse = "bounce@example.com" |
| 276 | h := app.Handler() |
| 277 | _, starts := futureDaySlots(t, h, path) |
| 278 | rec := strangerPost(t, h, path, holdForm(starts[0], "B", "bounce@example.com")) |
| 279 | if rec.Code != 200 || !strings.Contains(rec.Body.String(), "If nothing arrives") { |
| 280 | t.Errorf("refused mail: %d\n%s", rec.Code, rec.Body.String()) |
| 281 | } |
| 282 | // The slot is still held. |
| 283 | if again := strangerPost(t, h, path, holdForm(starts[0], "C", "c@example.com")); again.Code != http.StatusConflict { |
| 284 | t.Errorf("the slot was released: %d", again.Code) |
| 285 | } |
| 286 | } |
| 287 | |