| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/url" |
| 6 | "regexp" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | calendar "amadan.net/oficina/calendar/internal/calendar" |
| 12 | ) |
| 13 | |
| 14 | // createEvent posts the real form and returns the new event's path. |
| 15 | func createEvent(t *testing.T, h http.Handler, session *http.Cookie, form url.Values) string { |
| 16 | t.Helper() |
| 17 | // Read the SELECTED option, not the first one — that is what a |
| 18 | // browser submits when nobody touches the dropdown, and it is the |
| 19 | // personal calendar rather than the shared team one. An earlier |
| 20 | // version of this helper took the first option, silently created |
| 21 | // every test event on the team calendar, and made the isolation |
| 22 | // test look like an app bug. |
| 23 | if form.Get("calendar_id") == "" { |
| 24 | page := getAs(t, h, session, "/events/new").Body.String() |
| 25 | m := regexp.MustCompile(`<option value="(\d+)"[^>]*\bselected\b`).FindStringSubmatch(page) |
| 26 | if m == nil { |
| 27 | t.Fatalf("no calendar is selected by default on the create form:\n%s", page) |
| 28 | } |
| 29 | form.Set("calendar_id", m[1]) |
| 30 | } |
| 31 | rec := postAs(t, h, session, "/events", form) |
| 32 | if rec.Code != http.StatusSeeOther { |
| 33 | t.Fatalf("POST /events: status %d, want 303\n%s", rec.Code, rec.Body.String()) |
| 34 | } |
| 35 | return rec.Header().Get("Location") |
| 36 | } |
| 37 | |
| 38 | // The whole stack, end to end: a form submission becomes a sealed row, |
| 39 | // and the day page renders it back in the viewer's own zone. |
| 40 | func TestCreateAnEventAndSeeItOnTheDay(t *testing.T) { |
| 41 | app, session := signedIn(t, "someone@example.com") |
| 42 | h := app.Handler() |
| 43 | |
| 44 | loc := createEvent(t, h, session, url.Values{ |
| 45 | "title": {"Standup"}, |
| 46 | "start": {"2026-09-02T09:30"}, |
| 47 | "end": {"2026-09-02T09:45"}, |
| 48 | "kind": {"meeting"}, |
| 49 | "status": {"confirmed"}, |
| 50 | "repeat": {"none"}, |
| 51 | }) |
| 52 | if !strings.HasPrefix(loc, "/events/") { |
| 53 | t.Fatalf("created event redirected to %q", loc) |
| 54 | } |
| 55 | |
| 56 | page := getAs(t, h, session, "/day/2026-09-02") |
| 57 | if page.Code != http.StatusOK { |
| 58 | t.Fatalf("GET the day: status %d", page.Code) |
| 59 | } |
| 60 | body := page.Body.String() |
| 61 | if !strings.Contains(body, "Standup") { |
| 62 | t.Errorf("the event is missing from its own day:\n%s", body) |
| 63 | } |
| 64 | if !strings.Contains(body, "09:30") { |
| 65 | t.Errorf("the day page does not show the event's time:\n%s", body) |
| 66 | } |
| 67 | // The sentence is computed from real events, so it must have |
| 68 | // noticed this one. |
| 69 | if !strings.Contains(body, "One thing") { |
| 70 | t.Errorf("the day's sentence did not count the event:\n%s", body) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | // A title is content, and content is sealed. The proof is the raw |
| 75 | // SQLite file: nothing readable may be in it. |
| 76 | // |
| 77 | // This is the one rule's own test, and it greps the bytes rather than |
| 78 | // trusting that the sealing code was called — a column that was never |
| 79 | // sealed looks identical from Go and completely different on disk. |
| 80 | func TestTheDatabaseNeverHoldsAPlaintextTitle(t *testing.T) { |
| 81 | app, session := signedIn(t, "someone@example.com") |
| 82 | const secret = "Quarterly board meeting about the acquisition" |
| 83 | |
| 84 | createEvent(t, app.Handler(), session, url.Values{ |
| 85 | "title": {secret}, |
| 86 | "location": {"The unmarked room"}, |
| 87 | "notes": {"Do not tell anybody"}, |
| 88 | "start": {"2026-09-02T09:30"}, |
| 89 | "end": {"2026-09-02T10:30"}, |
| 90 | "kind": {"meeting"}, |
| 91 | "status": {"confirmed"}, |
| 92 | "repeat": {"none"}, |
| 93 | }) |
| 94 | |
| 95 | // WAL mode keeps recent writes out of the main file, so the |
| 96 | // checkpoint is not optional: without it this test passes by |
| 97 | // looking at a file the data has not reached yet. |
| 98 | if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { |
| 99 | t.Fatalf("checkpoint: %v", err) |
| 100 | } |
| 101 | |
| 102 | for _, needle := range []string{secret, "The unmarked room", "Do not tell anybody"} { |
| 103 | if found, where := app.GrepDatabaseForTest(t, needle); found { |
| 104 | t.Errorf("%q appears in plaintext in %s", needle, where) |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // The same rule for a booking: the page's text, the booker's name, |
| 110 | // address and note, and the management token are all content (booking |
| 111 | // spec §2.4, §9). A stranger's identity is who this team MEETS WITH, |
| 112 | // which is exactly what the bucket must not give up. |
| 113 | func TestTheDatabaseNeverHoldsAPlaintextBooker(t *testing.T) { |
| 114 | app, mailer, path, _ := publishedPageTitled(t, "Interrogation about the acquisition", "Bring nothing", "mondays 12-3, every day 9-5") |
| 115 | h := app.Handler() |
| 116 | _, starts := futureDaySlots(t, h, path) |
| 117 | form := holdForm(starts[0], "Dermot Lacey", "dermot.lacey@elsewhere.example") |
| 118 | form.Set("note", "The folder") |
| 119 | if rec := strangerPost(t, h, path, form); rec.Code != 200 { |
| 120 | t.Fatalf("hold: %d", rec.Code) |
| 121 | } |
| 122 | token := strings.TrimPrefix(reManage.FindString(mailer.body(0)), "/book/m/") |
| 123 | if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { |
| 124 | t.Fatalf("checkpoint: %v", err) |
| 125 | } |
| 126 | for _, needle := range []string{"Interrogation about the acquisition", "Bring nothing", "mondays 12-3", |
| 127 | "Dermot Lacey", "dermot.lacey@elsewhere.example", "The folder", token} { |
| 128 | if found, where := app.GrepDatabaseForTest(t, needle); found { |
| 129 | t.Errorf("%q appears in plaintext in %s", needle, where) |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // A row that isn't yours doesn't exist — 404, never 403, so no id can |
| 135 | // be enumerated by watching which ones answer differently. |
| 136 | func TestAnotherPersonsEventIsNotFound(t *testing.T) { |
| 137 | app, mine := signedIn(t, "me@example.com") |
| 138 | h := app.Handler() |
| 139 | |
| 140 | loc := createEvent(t, h, mine, url.Values{ |
| 141 | "title": {"Mine"}, |
| 142 | "start": {"2026-09-02T09:30"}, |
| 143 | "end": {"2026-09-02T10:00"}, |
| 144 | "kind": {"meeting"}, |
| 145 | "status": {"confirmed"}, |
| 146 | "repeat": {"none"}, |
| 147 | }) |
| 148 | |
| 149 | // A second person on the same instance. They share the team |
| 150 | // calendar, so this event must be on a personal one to be hidden — |
| 151 | // which is exactly why a new event defaults to the personal |
| 152 | // calendar rather than the shared one. |
| 153 | theirs := app.SignInForTest(t, "someone-else@example.com") |
| 154 | rec := getAs(t, h, theirs, loc) |
| 155 | if rec.Code != http.StatusNotFound { |
| 156 | t.Errorf("GET %s as another person: status %d, want 404", loc, rec.Code) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // A recurring event appears on every day the rule names, without any |
| 161 | // of those occurrences existing as rows. |
| 162 | func TestARecurringEventAppearsOnEveryDayItNames(t *testing.T) { |
| 163 | app, session := signedIn(t, "someone@example.com") |
| 164 | h := app.Handler() |
| 165 | |
| 166 | createEvent(t, h, session, url.Values{ |
| 167 | "title": {"Daily standup"}, |
| 168 | "start": {"2026-09-02T09:30"}, |
| 169 | "end": {"2026-09-02T09:45"}, |
| 170 | "kind": {"meeting"}, |
| 171 | "status": {"confirmed"}, |
| 172 | "repeat": {"daily"}, |
| 173 | }) |
| 174 | |
| 175 | for _, day := range []string{"2026-09-02", "2026-09-03", "2026-09-10"} { |
| 176 | body := getAs(t, h, session, "/day/"+day).Body.String() |
| 177 | if !strings.Contains(body, "Daily standup") { |
| 178 | t.Errorf("the series is missing from %s", day) |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // A form that fails validation must give back what was typed. Losing |
| 184 | // it is the single most common way a form wastes somebody's time. |
| 185 | func TestARejectedFormKeepsWhatWasTyped(t *testing.T) { |
| 186 | app, session := signedIn(t, "someone@example.com") |
| 187 | |
| 188 | rec := postAs(t, app.Handler(), session, "/events", url.Values{ |
| 189 | "title": {"A meeting that ends before it starts"}, |
| 190 | "calendar_id": {"1"}, |
| 191 | "start": {"2026-09-02T10:00"}, |
| 192 | "end": {"2026-09-02T09:00"}, |
| 193 | "kind": {"meeting"}, |
| 194 | "status": {"confirmed"}, |
| 195 | "repeat": {"none"}, |
| 196 | }) |
| 197 | if rec.Code != http.StatusUnprocessableEntity { |
| 198 | t.Fatalf("status %d, want 422", rec.Code) |
| 199 | } |
| 200 | body := rec.Body.String() |
| 201 | if !strings.Contains(body, "A meeting that ends before it starts") { |
| 202 | t.Error("the rejected form lost the title that was typed") |
| 203 | } |
| 204 | if !strings.Contains(body, "It cannot end before it starts.") { |
| 205 | t.Errorf("the rejected form does not say what was wrong:\n%s", body) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | // Every page must work with scripts off. The floor is that no page |
| 210 | // depends on one to be usable, so nothing here may carry an affordance |
| 211 | // that only a script can reach. |
| 212 | func TestEveryPageWorksWithoutScript(t *testing.T) { |
| 213 | app, session := signedIn(t, "someone@example.com") |
| 214 | h := app.Handler() |
| 215 | today := time.Now().Format("2006-01-02") |
| 216 | |
| 217 | for _, target := range []string{ |
| 218 | "/today", "/day/" + today, "/week/" + today, |
| 219 | "/month/" + today[:7], "/agenda", "/calendars", |
| 220 | "/events/new", "/settings", "/import", |
| 221 | } { |
| 222 | rec := follow(t, h, session, target) |
| 223 | if rec.Code != http.StatusOK { |
| 224 | t.Errorf("GET %s: status %d, want 200", target, rec.Code) |
| 225 | continue |
| 226 | } |
| 227 | body := rec.Body.String() |
| 228 | // An onclick is the tell that an affordance was built as a |
| 229 | // script hook rather than as a link or a form. |
| 230 | if strings.Contains(body, "onclick=") { |
| 231 | t.Errorf("GET %s carries an onclick handler; the scriptless floor is the product", target) |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | // The half-hour slots are what make "click a time to create an event" |
| 237 | // work with no JavaScript. They are ordinary links, and each one is |
| 238 | // named for a screen reader. |
| 239 | func TestTheDayOffersScriptlessSlotLinks(t *testing.T) { |
| 240 | app, session := signedIn(t, "someone@example.com") |
| 241 | body := getAs(t, app.Handler(), session, "/day/2026-09-02").Body.String() |
| 242 | |
| 243 | if n := strings.Count(body, "cal-slot"); n < 40 { |
| 244 | t.Errorf("the day offers %d slot links, want one per half hour", n) |
| 245 | } |
| 246 | if !strings.Contains(body, "Create an event at 09:30") { |
| 247 | t.Errorf("slot links carry no accessible name:\n%s", body[:min(2000, len(body))]) |
| 248 | } |
| 249 | if !strings.Contains(body, "/events/new?start=2026-09-02T09%3A30") && |
| 250 | !strings.Contains(body, "/events/new?start=2026-09-02T09:30") { |
| 251 | t.Error("a slot link does not prefill the form with its own time") |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | func min(a, b int) int { |
| 256 | if a < b { |
| 257 | return a |
| 258 | } |
| 259 | return b |
| 260 | } |
| 261 | |
| 262 | var _ = calendar.MaxOccurrencesPerSeries |
| 263 | |