package slidestest import ( "bufio" "context" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" "time" ) // presentLocation is what POST /d/{sqid}/present redirects to: the // presenter view for the new session, carrying the remote's capability // once. Both halves are captured because every later request in these // tests is one or the other — the session sqid is the address, and the // token is the permission. var presentLocation = regexp.MustCompile( `^/p/([23456789abcdefghjkmnpqrstuvwxyz]{10})/notes\?t=([A-Za-z0-9_-]{43})$`) // pairingCode finds the six-character code on the presenter view. The // alphabet is the unambiguous one (no O, no 0, no I, no 1), because a // code is read aloud and typed on a phone. // // Anchored to the element that displays it rather than loose in the // page: the remote token on the same page is base64url, and a run of // six of ITS characters inside this alphabet is a coin flip away — // an unanchored pattern would find the token about half the time. var pairingCode = regexp.MustCompile( `slides-code>([ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6})<`) // codeOn reads the pairing code off a presenter view. func codeOn(t *testing.T, page string) string { t.Helper() m := pairingCode.FindStringSubmatch(page) if m == nil { t.Fatalf("no pairing code on the presenter view:\n%s", page) } return m[1] } // startPresenting presses Present on a deck and returns the session's // sqid and the remote token it was shown once. func startPresenting(t *testing.T, a *testApp, deckPath string, cookies []*http.Cookie) (string, string) { t.Helper() rec := post(t, a, deckPath+"/present", nil, cookies...) if rec.Code != http.StatusSeeOther { t.Fatalf("POST %s/present: status %d, want 303\n%s", deckPath, rec.Code, rec.Body) } loc := rec.Header().Get("Location") m := presentLocation.FindStringSubmatch(loc) if m == nil { t.Fatalf("POST %s/present: Location = %q, want /p/{session}/notes?t={token}", deckPath, loc) } return m[1], m[2] } // command posts one remote command as the phone does: a form, with the // capability as a field. referer is sent when it is not empty, because // where the answer goes — 303 back, or 204 to a caller with no page to // return to — is part of what these tests are about. func command(t *testing.T, a *testApp, session, op, token, referer string, extra url.Values) *httptest.ResponseRecorder { t.Helper() form := url.Values{"op": {op}, "t": {token}} for k, vs := range extra { form[k] = vs } req := httptest.NewRequest(http.MethodPost, "/p/"+session+"/command", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", testOrigin) req.Header.Set("Sec-Fetch-Site", "same-origin") if referer != "" { req.Header.Set("Referer", referer) } rec := httptest.NewRecorder() a.ServeHTTP(rec, req) return rec } // TestPresentCreatesSessionWithCodeAndRemoteUrl: the Present button is // a form, pressing it opens a session, and the page it lands on shows // the pairing code, the remote's address and its QR — once, to the // presenter, and to nobody else. func TestPresentCreatesSessionWithCodeAndRemoteUrl(t *testing.T) { a := newApp(t) cookies := signedIn(t, a, allowedAddress) path := sampleDeck(t, a, cookies) viewer := mustGet(t, a, path, http.StatusOK, cookies).Body.String() if !strings.Contains(viewer, `action="`+path+`/present"`) { t.Errorf("the viewer offers no Present form:\n%s", viewer) } session, token := startPresenting(t, a, path, cookies) notes := mustGet(t, a, "/p/"+session+"/notes?t="+token, http.StatusOK, cookies).Body.String() code := codeOn(t, notes) remoteURL := testOrigin + "/r/" + code + "?t=" + token if !strings.Contains(notes, remoteURL) { t.Errorf("the presenter view does not show the remote address %q:\n%s", remoteURL, notes) } if !strings.Contains(notes, "slides-qr") || !strings.Contains(notes, code+`"`) { t.Errorf("the presenter view carries no QR naming the code:\n%s", notes) } // The presenter view is the presenter's own screen, so it carries // the notes the projector must not. if !strings.Contains(notes, "Speaker notes") && !strings.Contains(notes, "Presenter view") { t.Errorf("the presenter view does not name itself:\n%s", notes) } // Without the capability in the URL there is nothing to copy: the // pairing block is shown once, at the moment of pairing. again := mustGet(t, a, "/p/"+session+"/notes", http.StatusOK, cookies).Body.String() if strings.Contains(again, token) { t.Errorf("coming back to the presenter view shows the remote token again:\n%s", again) } if strings.Contains(again, "slides-qr") { t.Errorf("coming back to the presenter view shows the QR again:\n%s", again) } // The phone holds the capability and not a session: it reads the // notes and never the pairing block. phone := mustGet(t, a, "/p/"+session+"/notes?t="+token, http.StatusOK, nil).Body.String() if !strings.Contains(phone, "Speaker notes") && !strings.Contains(phone, "Presenter view") { t.Errorf("the token holder cannot read the presenter view:\n%s", phone) } if strings.Contains(phone, remoteURL) { t.Errorf("the token holder is shown the pairing block:\n%s", phone) } // A stranger with neither gets nothing. mustGet(t, a, "/p/"+session+"/notes", http.StatusNotFound, nil) // Pressing Present again reuses the same session rather than // opening a second one on the same deck for the same person. second, _ := startPresenting(t, a, path, cookies) if second != session { t.Errorf("pressing Present twice opened a second session: %q then %q", session, second) } // The fullscreen surface is the sqid's own capability: anybody who // has the address can watch, with no token and no session. surface := mustGet(t, a, "/p/"+session, http.StatusOK, nil).Body.String() if !strings.Contains(surface, `
`) || strings.Contains(surface, `
`) { t.Errorf("the projector is not a full-screen stage outside the ordinary page wrapper:\n%s", surface) } if strings.Contains(surface, "