| 1 | package slidestest |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "net/url" |
| 9 | "regexp" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | // presentLocation is what POST /d/{sqid}/present redirects to: the |
| 16 | // presenter view for the new session, carrying the remote's capability |
| 17 | // once. Both halves are captured because every later request in these |
| 18 | // tests is one or the other — the session sqid is the address, and the |
| 19 | // token is the permission. |
| 20 | var presentLocation = regexp.MustCompile( |
| 21 | `^/p/([23456789abcdefghjkmnpqrstuvwxyz]{10})/notes\?t=([A-Za-z0-9_-]{43})$`) |
| 22 | |
| 23 | // pairingCode finds the six-character code on the presenter view. The |
| 24 | // alphabet is the unambiguous one (no O, no 0, no I, no 1), because a |
| 25 | // code is read aloud and typed on a phone. |
| 26 | // |
| 27 | // Anchored to the element that displays it rather than loose in the |
| 28 | // page: the remote token on the same page is base64url, and a run of |
| 29 | // six of ITS characters inside this alphabet is a coin flip away — |
| 30 | // an unanchored pattern would find the token about half the time. |
| 31 | var pairingCode = regexp.MustCompile( |
| 32 | `slides-code>([ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6})<`) |
| 33 | |
| 34 | // codeOn reads the pairing code off a presenter view. |
| 35 | func codeOn(t *testing.T, page string) string { |
| 36 | t.Helper() |
| 37 | m := pairingCode.FindStringSubmatch(page) |
| 38 | if m == nil { |
| 39 | t.Fatalf("no pairing code on the presenter view:\n%s", page) |
| 40 | } |
| 41 | return m[1] |
| 42 | } |
| 43 | |
| 44 | // startPresenting presses Present on a deck and returns the session's |
| 45 | // sqid and the remote token it was shown once. |
| 46 | func startPresenting(t *testing.T, a *testApp, deckPath string, cookies []*http.Cookie) (string, string) { |
| 47 | t.Helper() |
| 48 | rec := post(t, a, deckPath+"/present", nil, cookies...) |
| 49 | if rec.Code != http.StatusSeeOther { |
| 50 | t.Fatalf("POST %s/present: status %d, want 303\n%s", deckPath, rec.Code, rec.Body) |
| 51 | } |
| 52 | loc := rec.Header().Get("Location") |
| 53 | m := presentLocation.FindStringSubmatch(loc) |
| 54 | if m == nil { |
| 55 | t.Fatalf("POST %s/present: Location = %q, want /p/{session}/notes?t={token}", deckPath, loc) |
| 56 | } |
| 57 | return m[1], m[2] |
| 58 | } |
| 59 | |
| 60 | // command posts one remote command as the phone does: a form, with the |
| 61 | // capability as a field. referer is sent when it is not empty, because |
| 62 | // where the answer goes — 303 back, or 204 to a caller with no page to |
| 63 | // return to — is part of what these tests are about. |
| 64 | func command(t *testing.T, a *testApp, session, op, token, referer string, extra url.Values) *httptest.ResponseRecorder { |
| 65 | t.Helper() |
| 66 | form := url.Values{"op": {op}, "t": {token}} |
| 67 | for k, vs := range extra { |
| 68 | form[k] = vs |
| 69 | } |
| 70 | req := httptest.NewRequest(http.MethodPost, "/p/"+session+"/command", |
| 71 | strings.NewReader(form.Encode())) |
| 72 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 73 | req.Header.Set("Origin", testOrigin) |
| 74 | req.Header.Set("Sec-Fetch-Site", "same-origin") |
| 75 | if referer != "" { |
| 76 | req.Header.Set("Referer", referer) |
| 77 | } |
| 78 | rec := httptest.NewRecorder() |
| 79 | a.ServeHTTP(rec, req) |
| 80 | return rec |
| 81 | } |
| 82 | |
| 83 | // TestPresentCreatesSessionWithCodeAndRemoteUrl: the Present button is |
| 84 | // a form, pressing it opens a session, and the page it lands on shows |
| 85 | // the pairing code, the remote's address and its QR — once, to the |
| 86 | // presenter, and to nobody else. |
| 87 | func TestPresentCreatesSessionWithCodeAndRemoteUrl(t *testing.T) { |
| 88 | a := newApp(t) |
| 89 | cookies := signedIn(t, a, allowedAddress) |
| 90 | path := sampleDeck(t, a, cookies) |
| 91 | |
| 92 | viewer := mustGet(t, a, path, http.StatusOK, cookies).Body.String() |
| 93 | if !strings.Contains(viewer, `action="`+path+`/present"`) { |
| 94 | t.Errorf("the viewer offers no Present form:\n%s", viewer) |
| 95 | } |
| 96 | |
| 97 | session, token := startPresenting(t, a, path, cookies) |
| 98 | |
| 99 | notes := mustGet(t, a, "/p/"+session+"/notes?t="+token, http.StatusOK, cookies).Body.String() |
| 100 | code := codeOn(t, notes) |
| 101 | remoteURL := testOrigin + "/r/" + code + "?t=" + token |
| 102 | if !strings.Contains(notes, remoteURL) { |
| 103 | t.Errorf("the presenter view does not show the remote address %q:\n%s", remoteURL, notes) |
| 104 | } |
| 105 | if !strings.Contains(notes, "slides-qr") || !strings.Contains(notes, code+`"`) { |
| 106 | t.Errorf("the presenter view carries no QR naming the code:\n%s", notes) |
| 107 | } |
| 108 | // The presenter view is the presenter's own screen, so it carries |
| 109 | // the notes the projector must not. |
| 110 | if !strings.Contains(notes, "Speaker notes") && !strings.Contains(notes, "Presenter view") { |
| 111 | t.Errorf("the presenter view does not name itself:\n%s", notes) |
| 112 | } |
| 113 | |
| 114 | // Without the capability in the URL there is nothing to copy: the |
| 115 | // pairing block is shown once, at the moment of pairing. |
| 116 | again := mustGet(t, a, "/p/"+session+"/notes", http.StatusOK, cookies).Body.String() |
| 117 | if strings.Contains(again, token) { |
| 118 | t.Errorf("coming back to the presenter view shows the remote token again:\n%s", again) |
| 119 | } |
| 120 | if strings.Contains(again, "slides-qr") { |
| 121 | t.Errorf("coming back to the presenter view shows the QR again:\n%s", again) |
| 122 | } |
| 123 | |
| 124 | // The phone holds the capability and not a session: it reads the |
| 125 | // notes and never the pairing block. |
| 126 | phone := mustGet(t, a, "/p/"+session+"/notes?t="+token, http.StatusOK, nil).Body.String() |
| 127 | if !strings.Contains(phone, "Speaker notes") && !strings.Contains(phone, "Presenter view") { |
| 128 | t.Errorf("the token holder cannot read the presenter view:\n%s", phone) |
| 129 | } |
| 130 | if strings.Contains(phone, remoteURL) { |
| 131 | t.Errorf("the token holder is shown the pairing block:\n%s", phone) |
| 132 | } |
| 133 | |
| 134 | // A stranger with neither gets nothing. |
| 135 | mustGet(t, a, "/p/"+session+"/notes", http.StatusNotFound, nil) |
| 136 | |
| 137 | // Pressing Present again reuses the same session rather than |
| 138 | // opening a second one on the same deck for the same person. |
| 139 | second, _ := startPresenting(t, a, path, cookies) |
| 140 | if second != session { |
| 141 | t.Errorf("pressing Present twice opened a second session: %q then %q", session, second) |
| 142 | } |
| 143 | |
| 144 | // The fullscreen surface is the sqid's own capability: anybody who |
| 145 | // has the address can watch, with no token and no session. |
| 146 | surface := mustGet(t, a, "/p/"+session, http.StatusOK, nil).Body.String() |
| 147 | if !strings.Contains(surface, `<main id="main" slides-present-stage>`) || strings.Contains(surface, `<main rst-page id="main">`) { |
| 148 | t.Errorf("the projector is not a full-screen stage outside the ordinary page wrapper:\n%s", surface) |
| 149 | } |
| 150 | if strings.Contains(surface, "<header estilo-bar") || strings.Contains(surface, "estilo-account") { |
| 151 | t.Errorf("the projector surface carries app chrome:\n%s", surface) |
| 152 | } |
| 153 | if !strings.Contains(surface, "<section slides-slide") { |
| 154 | t.Errorf("the fullscreen surface renders no slide:\n%s", surface) |
| 155 | } |
| 156 | if !strings.Contains(surface, "1 / 8") { |
| 157 | t.Errorf("the fullscreen surface does not say where in the deck it is:\n%s", surface) |
| 158 | } |
| 159 | if !strings.Contains(surface, "<progress") { |
| 160 | t.Errorf("the fullscreen surface has no progress element:\n%s", surface) |
| 161 | } |
| 162 | if strings.Contains(surface, "Speaker notes") { |
| 163 | t.Errorf("the projector surface carries the speaker notes:\n%s", surface) |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | // TestRemoteCommandAdvancesSlideAndPublishes: a phone posts "next" and |
| 168 | // every page watching the session hears about it — which is the whole |
| 169 | // mechanism, so it is asserted through the real stream rather than |
| 170 | // through the row. |
| 171 | func TestRemoteCommandAdvancesSlideAndPublishes(t *testing.T) { |
| 172 | a := newApp(t) |
| 173 | cookies := signedIn(t, a, allowedAddress) |
| 174 | path := sampleDeck(t, a, cookies) |
| 175 | session, token := startPresenting(t, a, path, cookies) |
| 176 | |
| 177 | srv := httptest.NewServer(a) |
| 178 | defer srv.Close() |
| 179 | |
| 180 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| 181 | defer cancel() |
| 182 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/p/"+session+"/stream", nil) |
| 183 | if err != nil { |
| 184 | t.Fatalf("build the stream request: %v", err) |
| 185 | } |
| 186 | resp, err := http.DefaultClient.Do(req) |
| 187 | if err != nil { |
| 188 | t.Fatalf("open the stream: %v", err) |
| 189 | } |
| 190 | defer resp.Body.Close() |
| 191 | if got := resp.Header.Get("Content-Type"); got != "text/event-stream" { |
| 192 | t.Fatalf("the stream's Content-Type is %q, want text/event-stream", got) |
| 193 | } |
| 194 | events := bufio.NewReader(resp.Body) |
| 195 | |
| 196 | // One event on connect, so a page that opened the stream after the |
| 197 | // deck moved is right rather than merely hopeful. |
| 198 | if data := nextSlideEvent(t, events); !strings.Contains(data, `"slide":1`) { |
| 199 | t.Errorf("the connect event is %q, want slide 1", data) |
| 200 | } |
| 201 | |
| 202 | rec := command(t, a, session, "next", token, "", nil) |
| 203 | if rec.Code != http.StatusNoContent { |
| 204 | t.Fatalf("POST command with no page to return to: status %d, want 204\n%s", rec.Code, rec.Body) |
| 205 | } |
| 206 | if data := nextSlideEvent(t, events); !strings.Contains(data, `"slide":2`) { |
| 207 | t.Errorf("after next the event is %q, want slide 2", data) |
| 208 | } |
| 209 | |
| 210 | // A browser form submission goes back where it came from. |
| 211 | back := testOrigin + "/p/" + session + "/remote?t=" + token |
| 212 | rec = command(t, a, session, "next", token, back, nil) |
| 213 | if rec.Code != http.StatusSeeOther { |
| 214 | t.Fatalf("POST command from a page: status %d, want 303\n%s", rec.Code, rec.Body) |
| 215 | } |
| 216 | if loc := rec.Header().Get("Location"); loc != back { |
| 217 | t.Errorf("the command redirected to %q, want %q", loc, back) |
| 218 | } |
| 219 | if data := nextSlideEvent(t, events); !strings.Contains(data, `"slide":3`) { |
| 220 | t.Errorf("after a second next the event is %q, want slide 3", data) |
| 221 | } |
| 222 | |
| 223 | // Blackout is its own state, and it does not move the deck. |
| 224 | rec = command(t, a, session, "blackout", token, "", nil) |
| 225 | if rec.Code != http.StatusNoContent { |
| 226 | t.Fatalf("POST blackout: status %d, want 204\n%s", rec.Code, rec.Body) |
| 227 | } |
| 228 | data := nextSlideEvent(t, events) |
| 229 | if !strings.Contains(data, `"slide":3`) || !strings.Contains(data, `"blackout":true`) { |
| 230 | t.Errorf("after blackout the event is %q, want slide 3 blacked out", data) |
| 231 | } |
| 232 | |
| 233 | // goto names a slide; prev walks back; neither runs off an end. |
| 234 | command(t, a, session, "goto", token, "", url.Values{"slide": {"8"}}) |
| 235 | if data := nextSlideEvent(t, events); !strings.Contains(data, `"slide":8`) { |
| 236 | t.Errorf("after goto 8 the event is %q, want slide 8", data) |
| 237 | } |
| 238 | command(t, a, session, "next", token, "", nil) |
| 239 | if data := nextSlideEvent(t, events); !strings.Contains(data, `"slide":8`) { |
| 240 | t.Errorf("next past the last slide moved to %q, want to stay on slide 8", data) |
| 241 | } |
| 242 | |
| 243 | // And the surface everyone is watching really shows it. |
| 244 | surface := mustGet(t, a, "/p/"+session, http.StatusOK, nil).Body.String() |
| 245 | if !strings.Contains(surface, "8 / 8") { |
| 246 | t.Errorf("the surface does not show the session's own slide:\n%s", surface) |
| 247 | } |
| 248 | if !strings.Contains(surface, `data-blackout="true"`) { |
| 249 | t.Errorf("the surface does not carry the blackout state:\n%s", surface) |
| 250 | } |
| 251 | if !strings.Contains(surface, "blacked out") { |
| 252 | t.Errorf("the blackout is carried by colour alone, with no words:\n%s", surface) |
| 253 | } |
| 254 | |
| 255 | // The two URLs the script uses come down as attributes rather than |
| 256 | // being built in the browser (codex 5): on the projector they are |
| 257 | // the session's own, and static/present.js reads them instead of |
| 258 | // assembling a /p/ path out of a sqid it may not have. |
| 259 | if !strings.Contains(surface, `data-stream="/p/`+session+`/stream"`) { |
| 260 | t.Errorf("the surface does not name its own stream:\n%s", surface) |
| 261 | } |
| 262 | if !strings.Contains(surface, `data-fragment="/p/`+session+`?slide={n}&fragment=1"`) { |
| 263 | t.Errorf("the surface does not name its own fragment URL:\n%s", surface) |
| 264 | } |
| 265 | |
| 266 | // The fragment is the same slide with no page around it — what the |
| 267 | // script swaps in, and never a nested layout. |
| 268 | fragment := mustGet(t, a, "/p/"+session+"?slide=2&fragment=1", http.StatusOK, nil).Body.String() |
| 269 | if !strings.HasPrefix(strings.TrimSpace(fragment), "<section slides-slide") { |
| 270 | t.Errorf("the fragment is not a bare slide:\n%s", fragment) |
| 271 | } |
| 272 | if strings.Contains(fragment, "<html") { |
| 273 | t.Errorf("the fragment carries the whole page:\n%s", fragment) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | // nextSlideEvent reads events until a `slide` one arrives, returning |
| 278 | // its data line. Pings are skipped; the request's own context is the |
| 279 | // timeout, so a stream that never speaks fails the test rather than |
| 280 | // hanging it. |
| 281 | func nextSlideEvent(t *testing.T, r *bufio.Reader) string { |
| 282 | t.Helper() |
| 283 | var event string |
| 284 | for { |
| 285 | line, err := r.ReadString('\n') |
| 286 | if err != nil { |
| 287 | t.Fatalf("reading the stream: %v", err) |
| 288 | } |
| 289 | line = strings.TrimRight(line, "\r\n") |
| 290 | switch { |
| 291 | case strings.HasPrefix(line, "event: "): |
| 292 | event = strings.TrimPrefix(line, "event: ") |
| 293 | case strings.HasPrefix(line, "data: "): |
| 294 | if event == "slide" { |
| 295 | return strings.TrimPrefix(line, "data: ") |
| 296 | } |
| 297 | } |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | // TestWrongRemoteTokenIs404: the token is the whole permission to move |
| 302 | // somebody else's deck, and a wrong one is not a 403 — a 403 would |
| 303 | // confirm the session is real to whoever is guessing. |
| 304 | func TestWrongRemoteTokenIs404(t *testing.T) { |
| 305 | a := newApp(t) |
| 306 | cookies := signedIn(t, a, allowedAddress) |
| 307 | path := sampleDeck(t, a, cookies) |
| 308 | session, token := startPresenting(t, a, path, cookies) |
| 309 | |
| 310 | wrong := strings.Repeat("a", len(token)) |
| 311 | if rec := command(t, a, session, "next", wrong, "", nil); rec.Code != http.StatusNotFound { |
| 312 | t.Errorf("a command with the wrong token: status %d, want 404", rec.Code) |
| 313 | } |
| 314 | if rec := command(t, a, session, "next", "", "", nil); rec.Code != http.StatusNotFound { |
| 315 | t.Errorf("a command with no token: status %d, want 404", rec.Code) |
| 316 | } |
| 317 | mustGet(t, a, "/p/"+session+"/remote?t="+wrong, http.StatusNotFound, nil) |
| 318 | mustGet(t, a, "/p/"+session+"/remote", http.StatusNotFound, nil) |
| 319 | // Signed in as the presenter is not the same as holding the remote: |
| 320 | // the capability is what moves the deck. |
| 321 | if rec := command(t, a, session, "next", wrong, "", nil); rec.Code != http.StatusNotFound { |
| 322 | t.Errorf("a command with the wrong token from the presenter: status %d, want 404", rec.Code) |
| 323 | } |
| 324 | |
| 325 | // And nothing moved. |
| 326 | surface := mustGet(t, a, "/p/"+session, http.StatusOK, nil).Body.String() |
| 327 | if !strings.Contains(surface, "1 / 8") { |
| 328 | t.Errorf("a refused command moved the deck anyway:\n%s", surface) |
| 329 | } |
| 330 | |
| 331 | // An unknown session is a 404 too, with the right token or without. |
| 332 | mustGet(t, a, "/p/2222222222", http.StatusNotFound, nil) |
| 333 | if rec := command(t, a, "2222222222", "next", token, "", nil); rec.Code != http.StatusNotFound { |
| 334 | t.Errorf("a command against an unknown session: status %d, want 404", rec.Code) |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | // TestEndedSessionIs410: a talk that is over is gone, not missing. The |
| 339 | // distinction is the point — somebody following a link to last week's |
| 340 | // session should be told it finished, and a dead pairing code should |
| 341 | // tell a stranger nothing at all. |
| 342 | func TestEndedSessionIs410(t *testing.T) { |
| 343 | a := newApp(t) |
| 344 | cookies := signedIn(t, a, allowedAddress) |
| 345 | path := sampleDeck(t, a, cookies) |
| 346 | session, token := startPresenting(t, a, path, cookies) |
| 347 | |
| 348 | notes := mustGet(t, a, "/p/"+session+"/notes?t="+token, http.StatusOK, cookies).Body.String() |
| 349 | code := codeOn(t, notes) |
| 350 | mustGet(t, a, "/r/"+code+"?t="+token, http.StatusSeeOther, nil) |
| 351 | |
| 352 | if rec := command(t, a, session, "end", token, "", nil); rec.Code != http.StatusNoContent { |
| 353 | t.Fatalf("ending the session: status %d, want 204\n%s", rec.Code, rec.Body) |
| 354 | } |
| 355 | |
| 356 | for _, target := range []string{ |
| 357 | "/p/" + session, |
| 358 | "/p/" + session + "/notes?t=" + token, |
| 359 | "/p/" + session + "/remote?t=" + token, |
| 360 | } { |
| 361 | rec := getHTML(t, a, target, cookies...) |
| 362 | if rec.Code != http.StatusGone { |
| 363 | t.Errorf("GET %s after the session ended: status %d, want 410", target, rec.Code) |
| 364 | } |
| 365 | if !strings.Contains(rec.Body.String(), "over") { |
| 366 | t.Errorf("GET %s says nothing about the session being over:\n%s", target, rec.Body) |
| 367 | } |
| 368 | } |
| 369 | if rec := command(t, a, session, "next", token, "", nil); rec.Code != http.StatusGone { |
| 370 | t.Errorf("a command against an ended session: status %d, want 410", rec.Code) |
| 371 | } |
| 372 | // A dead code is a 404: a gone would tell whoever typed it that the |
| 373 | // code was once real. |
| 374 | mustGet(t, a, "/r/"+code+"?t="+token, http.StatusNotFound, nil) |
| 375 | mustGet(t, a, "/r/"+code, http.StatusNotFound, nil) |
| 376 | } |
| 377 | |
| 378 | // TestDeletingADeckEndsItsPresentations: deleting a deck takes its live |
| 379 | // talks with it. |
| 380 | // |
| 381 | // deleteDeck cascaded the update log, the grants and the share links |
| 382 | // and left the presentations behind, so a projector went on holding a |
| 383 | // session whose deck no longer existed, a pairing code went on being |
| 384 | // live, and every page watching sat there waiting for an event that |
| 385 | // was never coming. The row goes in the same transaction as the deck, |
| 386 | // and the hub is told the session is over, which is what a projector |
| 387 | // and a phone act on. |
| 388 | func TestDeletingADeckEndsItsPresentations(t *testing.T) { |
| 389 | a := newApp(t) |
| 390 | cookies := signedIn(t, a, allowedAddress) |
| 391 | path := sampleDeck(t, a, cookies) |
| 392 | session, token := startPresenting(t, a, path, cookies) |
| 393 | |
| 394 | notes := mustGet(t, a, "/p/"+session+"/notes?t="+token, http.StatusOK, cookies).Body.String() |
| 395 | code := codeOn(t, notes) |
| 396 | mustGet(t, a, "/p/"+session, http.StatusOK, nil) |
| 397 | |
| 398 | // A projector watching, so this asserts what a projector actually |
| 399 | // hears rather than only what the database holds. |
| 400 | srv := httptest.NewServer(a) |
| 401 | defer srv.Close() |
| 402 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| 403 | defer cancel() |
| 404 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/p/"+session+"/stream", nil) |
| 405 | if err != nil { |
| 406 | t.Fatalf("build the stream request: %v", err) |
| 407 | } |
| 408 | resp, err := http.DefaultClient.Do(req) |
| 409 | if err != nil { |
| 410 | t.Fatalf("open the stream: %v", err) |
| 411 | } |
| 412 | defer resp.Body.Close() |
| 413 | events := bufio.NewReader(resp.Body) |
| 414 | if data := nextSlideEvent(t, events); !strings.Contains(data, `"ended":false`) { |
| 415 | t.Fatalf("the connect event is %q, want a live session", data) |
| 416 | } |
| 417 | |
| 418 | rec := post(t, a, path+"/delete", nil, cookies...) |
| 419 | if rec.Code != http.StatusSeeOther { |
| 420 | t.Fatalf("POST %s/delete: status %d, want 303\n%s", path, rec.Code, rec.Body) |
| 421 | } |
| 422 | |
| 423 | if data := nextSlideEvent(t, events); !strings.Contains(data, `"ended":true`) { |
| 424 | t.Errorf("the event after the deck was deleted is %q, want the session ended", data) |
| 425 | } |
| 426 | |
| 427 | // Gone or never-was are both honest here; still answering is not. |
| 428 | if rec := get(t, a, "/p/"+session); rec.Code != http.StatusNotFound && rec.Code != http.StatusGone { |
| 429 | t.Errorf("GET /p/%s after its deck was deleted: status %d, want 404 or 410\n%s", session, rec.Code, rec.Body) |
| 430 | } |
| 431 | // The pairing code is a 404 rather than a gone, for the reason a |
| 432 | // dead code always is: it tells whoever typed it nothing. |
| 433 | mustGet(t, a, "/r/"+code, http.StatusNotFound, nil) |
| 434 | mustGet(t, a, "/r/"+code+"?t="+token, http.StatusNotFound, nil) |
| 435 | |
| 436 | var left int64 |
| 437 | if err := a.db.G.Raw("SELECT COUNT(*) FROM presentations").Scan(&left).Error; err != nil { |
| 438 | t.Fatalf("count presentations: %v", err) |
| 439 | } |
| 440 | if left != 0 { |
| 441 | t.Errorf("%d presentation rows outlived their deck, want 0", left) |
| 442 | } |
| 443 | } |
| 444 | |
| 445 | // TestScriptlessRemoteButtonsAreForms: the remote is four buttons, and |
| 446 | // with scripts off each one is a form that posts and comes back. This |
| 447 | // is §8's floor for the one screen most likely to be used on a phone |
| 448 | // with something blocking the script. |
| 449 | func TestScriptlessRemoteButtonsAreForms(t *testing.T) { |
| 450 | a := newApp(t) |
| 451 | cookies := signedIn(t, a, allowedAddress) |
| 452 | path := sampleDeck(t, a, cookies) |
| 453 | session, token := startPresenting(t, a, path, cookies) |
| 454 | |
| 455 | remote := mustGet(t, a, "/p/"+session+"/remote?t="+token, http.StatusOK, nil).Body.String() |
| 456 | if n := strings.Count(remote, `<form method="post"`); n != 4 { |
| 457 | t.Errorf("the remote has %d forms, want 4 (previous, next, blackout, end):\n%s", n, remote) |
| 458 | } |
| 459 | for _, op := range []string{"prev", "next", "blackout", "end"} { |
| 460 | if !strings.Contains(remote, `value="`+op+`"`) { |
| 461 | t.Errorf("the remote has no %s button:\n%s", op, remote) |
| 462 | } |
| 463 | } |
| 464 | if !strings.Contains(remote, `action="/p/`+session+`/command"`) { |
| 465 | t.Errorf("the remote's forms do not post commands:\n%s", remote) |
| 466 | } |
| 467 | if !strings.Contains(remote, `value="`+token+`"`) { |
| 468 | t.Errorf("the remote's forms carry no capability:\n%s", remote) |
| 469 | } |
| 470 | // The phone shows what is on screen now and what is coming, so the |
| 471 | // presenter is not reading the room off a projector behind them. |
| 472 | // A picture of each, not a live copy: two whole slides of DOM on a |
| 473 | // phone over a hotel wifi is a lot of markup for a preview |
| 474 | // (presentation.go's slideThumbImg). |
| 475 | if !strings.Contains(remote, `src="/p/`+session+`/slides/`) { |
| 476 | t.Errorf("the remote shows no picture of the slide:\n%s", remote) |
| 477 | } |
| 478 | if strings.Contains(remote, "<section slides-slide") { |
| 479 | t.Errorf("the remote still renders a whole slide:\n%s", remote) |
| 480 | } |
| 481 | |
| 482 | // Blacking out changes what the button says, not only how it looks. |
| 483 | if !strings.Contains(remote, "Black the screen") { |
| 484 | t.Errorf("the remote's blackout button is unlabelled:\n%s", remote) |
| 485 | } |
| 486 | command(t, a, session, "blackout", token, "", nil) |
| 487 | blacked := mustGet(t, a, "/p/"+session+"/remote?t="+token, http.StatusOK, nil).Body.String() |
| 488 | if !strings.Contains(blacked, "Show the screen") { |
| 489 | t.Errorf("the blackout button does not change its words when the screen is black:\n%s", blacked) |
| 490 | } |
| 491 | |
| 492 | // The pair page is the way in for a typed code, and it is a form |
| 493 | // too — the QR is the shortcut, never the only route. |
| 494 | notes := mustGet(t, a, "/p/"+session+"/notes?t="+token, http.StatusOK, cookies).Body.String() |
| 495 | code := codeOn(t, notes) |
| 496 | pair := mustGet(t, a, "/r/"+code, http.StatusOK, nil).Body.String() |
| 497 | if !strings.Contains(pair, "<form") { |
| 498 | t.Errorf("the pair page has no form:\n%s", pair) |
| 499 | } |
| 500 | if !strings.Contains(pair, code) { |
| 501 | t.Errorf("the pair page does not carry the code that reached it:\n%s", pair) |
| 502 | } |
| 503 | // The QR carries both halves, so scanning it lands on the remote. |
| 504 | rec := get(t, a, "/r/"+code+"?t="+token) |
| 505 | if rec.Code != http.StatusSeeOther { |
| 506 | t.Fatalf("GET /r/{code}?t=: status %d, want 303\n%s", rec.Code, rec.Body) |
| 507 | } |
| 508 | if loc := rec.Header().Get("Location"); loc != "/p/"+session+"/remote?t="+token { |
| 509 | t.Errorf("the pair redirect goes to %q, want the remote", loc) |
| 510 | } |
| 511 | } |
| 512 | |
| 513 | // TestFollowOfferedOnlyWhileLive: a share link offers to follow the |
| 514 | // presenter while somebody is presenting, and goes back to being an |
| 515 | // ordinary read-only deck when nobody is. |
| 516 | func TestFollowOfferedOnlyWhileLive(t *testing.T) { |
| 517 | a := newApp(t) |
| 518 | cookies := signedIn(t, a, allowedAddress) |
| 519 | path := sampleDeck(t, a, cookies) |
| 520 | token := mintShareLink(t, a, path, cookies) |
| 521 | share := "/s/" + token |
| 522 | |
| 523 | quiet := mustGet(t, a, share, http.StatusOK, nil).Body.String() |
| 524 | if strings.Contains(quiet, "Follow the presenter") { |
| 525 | t.Errorf("a share link offers to follow with nobody presenting:\n%s", quiet) |
| 526 | } |
| 527 | // Nothing to follow: the flag is not a way to reach a session that |
| 528 | // does not exist. |
| 529 | rec := get(t, a, share+"?follow=1") |
| 530 | if rec.Code != http.StatusSeeOther { |
| 531 | t.Errorf("?follow=1 with nobody presenting: status %d, want 303", rec.Code) |
| 532 | } |
| 533 | |
| 534 | session, remoteToken := startPresenting(t, a, path, cookies) |
| 535 | command(t, a, session, "next", remoteToken, "", nil) |
| 536 | |
| 537 | offered := mustGet(t, a, share, http.StatusOK, nil).Body.String() |
| 538 | if !strings.Contains(offered, "Follow the presenter") { |
| 539 | t.Errorf("a live session is not offered on the share link:\n%s", offered) |
| 540 | } |
| 541 | if !strings.Contains(offered, share+"?follow=1") { |
| 542 | t.Errorf("the follow offer does not link to the follow surface:\n%s", offered) |
| 543 | } |
| 544 | |
| 545 | follow := mustGet(t, a, share+"?follow=1", http.StatusOK, nil).Body.String() |
| 546 | if !strings.Contains(follow, "2 / 8") { |
| 547 | t.Errorf("following does not show the presenter's own slide:\n%s", follow) |
| 548 | } |
| 549 | if !strings.Contains(follow, `http-equiv="refresh"`) { |
| 550 | t.Errorf("the follow surface does not refresh with scripts off:\n%s", follow) |
| 551 | } |
| 552 | if !strings.Contains(follow, share+`"`) { |
| 553 | t.Errorf("the follow surface offers no way back to browsing:\n%s", follow) |
| 554 | } |
| 555 | // Read-only means read-only: an audience cannot move the deck. |
| 556 | if strings.Contains(follow, "<form") { |
| 557 | t.Errorf("the follow surface carries a control:\n%s", follow) |
| 558 | } |
| 559 | if strings.Contains(follow, remoteToken) { |
| 560 | t.Errorf("the follow surface leaks the remote capability:\n%s", follow) |
| 561 | } |
| 562 | // And it never leaks the notes, which is what a share link is not. |
| 563 | if strings.Contains(follow, "Speaker notes") { |
| 564 | t.Errorf("the follow surface carries the speaker notes:\n%s", follow) |
| 565 | } |
| 566 | |
| 567 | command(t, a, session, "end", remoteToken, "", nil) |
| 568 | after := mustGet(t, a, share, http.StatusOK, nil).Body.String() |
| 569 | if strings.Contains(after, "Follow the presenter") { |
| 570 | t.Errorf("the follow offer outlives the session:\n%s", after) |
| 571 | } |
| 572 | if rec := get(t, a, share+"?follow=1"); rec.Code != http.StatusSeeOther { |
| 573 | t.Errorf("following an ended session: status %d, want 303 back to the deck", rec.Code) |
| 574 | } |
| 575 | } |
| 576 | |
| 577 | // TestEveryPresentingPageSaysWhichSlideItShows: every page that loads |
| 578 | // static/present.js puts the slide it was rendered for on the script's |
| 579 | // root as data-slide, the same 1-based number the stream's events |
| 580 | // carry. The presenter view and the remote once had none, so the |
| 581 | // script took them to be on slide 0 and reloaded on every event — |
| 582 | // the stream's first included — forever (polish audit B3). The |
| 583 | // browser half is TestBrowserPresentingScreensHoldStill; this one is |
| 584 | // in the plain suite so the gate holds it too. |
| 585 | func TestEveryPresentingPageSaysWhichSlideItShows(t *testing.T) { |
| 586 | a := newApp(t) |
| 587 | cookies := signedIn(t, a, allowedAddress) |
| 588 | path := sampleDeck(t, a, cookies) |
| 589 | session, token := startPresenting(t, a, path, cookies) |
| 590 | command(t, a, session, "next", token, "", nil) |
| 591 | |
| 592 | for _, page := range []string{ |
| 593 | "/p/" + session + "?t=" + token, |
| 594 | "/p/" + session + "/notes?t=" + token, |
| 595 | "/p/" + session + "/remote?t=" + token, |
| 596 | } { |
| 597 | body := mustGet(t, a, page, http.StatusOK, cookies).Body.String() |
| 598 | if !strings.Contains(body, `static/present`) { |
| 599 | t.Errorf("%s no longer loads present.js; this test is about the pages that do", page) |
| 600 | } |
| 601 | if !strings.Contains(body, `data-slide="2"`) { |
| 602 | t.Errorf("%s: the script root does not say data-slide=\"2\" after one next:\n%s", page, body) |
| 603 | } |
| 604 | } |
| 605 | } |
| 606 | |