oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1package slidestest
2
3import (
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.
20var 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.
31var pairingCode = regexp.MustCompile(
32 `slides-code>([ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6})<`)
33
34// codeOn reads the pairing code off a presenter view.
35func 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.
46func 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.
64func 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.
87func 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.
171func 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}&amp;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.
281func 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.
304func 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.
342func 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.
388func 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.
449func 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.
516func 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.
585func 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