| 1 | package slidestest |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/hex" |
| 6 | "net/http" |
| 7 | "net/url" |
| 8 | "regexp" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | // newToken finds the token the share page was redirected with. It is |
| 14 | // shown once, in the query string of that one redirect, and nowhere |
| 15 | // else ever again. |
| 16 | var newToken = regexp.MustCompile(`\?new=([A-Za-z0-9_-]{43})`) |
| 17 | |
| 18 | // revokeAction finds a link row's revoke form on the share page. |
| 19 | var revokeAction = regexp.MustCompile(`/share/(\d+)/revoke`) |
| 20 | |
| 21 | // mintShareLink posts the create form on a deck's share page and |
| 22 | // returns the token it was shown once. |
| 23 | func mintShareLink(t *testing.T, a *testApp, deckPath string, cookies []*http.Cookie) string { |
| 24 | t.Helper() |
| 25 | rec := post(t, a, deckPath+"/share", nil, cookies...) |
| 26 | if rec.Code != http.StatusSeeOther { |
| 27 | t.Fatalf("POST %s/share: status %d, want 303\n%s", deckPath, rec.Code, rec.Body) |
| 28 | } |
| 29 | loc := rec.Header().Get("Location") |
| 30 | m := newToken.FindStringSubmatch(loc) |
| 31 | if m == nil { |
| 32 | t.Fatalf("POST %s/share: Location = %q, carries no new token", deckPath, loc) |
| 33 | } |
| 34 | return m[1] |
| 35 | } |
| 36 | |
| 37 | // TestShareLinkShownOnceAndOpensViewer: minting shows the URL once, |
| 38 | // coming back never shows it again, and the URL itself opens the deck |
| 39 | // for somebody with no session at all. |
| 40 | func TestShareLinkShownOnceAndOpensViewer(t *testing.T) { |
| 41 | a := newApp(t) |
| 42 | cookies := signedIn(t, a, allowedAddress) |
| 43 | path := createDeck(t, a, cookies, "Board update") |
| 44 | |
| 45 | empty := mustGet(t, a, path+"/share", http.StatusOK, cookies).Body.String() |
| 46 | if !strings.Contains(empty, "No share links") { |
| 47 | t.Errorf("a deck with no share links does not say so:\n%s", empty) |
| 48 | } |
| 49 | |
| 50 | token := mintShareLink(t, a, path, cookies) |
| 51 | |
| 52 | shown := mustGet(t, a, path+"/share?new="+token, http.StatusOK, cookies).Body.String() |
| 53 | full := testOrigin + "/s/" + token |
| 54 | if !strings.Contains(shown, full) { |
| 55 | t.Errorf("the share page does not show the new link %q:\n%s", full, shown) |
| 56 | } |
| 57 | if !strings.Contains(shown, "readonly") { |
| 58 | t.Errorf("the new link is not in a read-only field:\n%s", shown) |
| 59 | } |
| 60 | |
| 61 | // Come back for it and it is gone: only the hash was stored, so |
| 62 | // there is nothing left to show. |
| 63 | again := mustGet(t, a, path+"/share", http.StatusOK, cookies).Body.String() |
| 64 | if strings.Contains(again, token) { |
| 65 | t.Errorf("the share page shows the token a second time:\n%s", again) |
| 66 | } |
| 67 | |
| 68 | // No cookies at all: the link is the whole capability. |
| 69 | page := mustGet(t, a, "/s/"+token, http.StatusOK, nil).Body.String() |
| 70 | if !strings.Contains(page, "Board update") { |
| 71 | t.Errorf("the share link does not open the deck:\n%s", page) |
| 72 | } |
| 73 | if !strings.Contains(page, "<section slides-slide") { |
| 74 | t.Errorf("the share link renders no slide:\n%s", page) |
| 75 | } |
| 76 | // It is the same paged viewer, and the same phone preview. |
| 77 | mustGet(t, a, "/s/"+token+"?slide=1", http.StatusOK, nil) |
| 78 | mustGet(t, a, "/s/"+token+"?slide=2", http.StatusNotFound, nil) |
| 79 | phone := mustGet(t, a, "/s/"+token+"?vp=phone", http.StatusOK, nil).Body.String() |
| 80 | if !strings.Contains(phone, `data-vp="phone"`) { |
| 81 | t.Errorf("the share link ignores ?vp=phone:\n%s", phone) |
| 82 | } |
| 83 | // ?present=1 is the same capability with the chrome taken off. |
| 84 | present := mustGet(t, a, "/s/"+token+"?present=1", http.StatusOK, nil).Body.String() |
| 85 | if !strings.Contains(present, "slides-present") { |
| 86 | t.Errorf("?present=1 does not render the presenting surface:\n%s", present) |
| 87 | } |
| 88 | |
| 89 | // A token nobody ever minted is not a different answer. |
| 90 | mustGet(t, a, "/s/"+strings.Repeat("a", 43), http.StatusNotFound, nil) |
| 91 | |
| 92 | // The views are counted, and the share page says how many. |
| 93 | counted := mustGet(t, a, path+"/share", http.StatusOK, cookies).Body.String() |
| 94 | if !strings.Contains(counted, "5 views") { |
| 95 | t.Errorf("the share page does not count the five views taken above:\n%s", counted) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // TestRevokedShareLinkIs404: revoking turns the URL off, and a revoked |
| 100 | // link is not told apart from one that never existed. |
| 101 | func TestRevokedShareLinkIs404(t *testing.T) { |
| 102 | a := newApp(t) |
| 103 | cookies := signedIn(t, a, allowedAddress) |
| 104 | path := createDeck(t, a, cookies, "Board update") |
| 105 | |
| 106 | token := mintShareLink(t, a, path, cookies) |
| 107 | mustGet(t, a, "/s/"+token, http.StatusOK, nil) |
| 108 | |
| 109 | page := mustGet(t, a, path+"/share", http.StatusOK, cookies).Body.String() |
| 110 | m := revokeAction.FindStringSubmatch(page) |
| 111 | if m == nil { |
| 112 | t.Fatalf("the share page carries no revoke form:\n%s", page) |
| 113 | } |
| 114 | if rec := post(t, a, path+"/share/"+m[1]+"/revoke", nil, cookies...); rec.Code != http.StatusSeeOther { |
| 115 | t.Fatalf("POST revoke: status %d, want 303\n%s", rec.Code, rec.Body) |
| 116 | } |
| 117 | |
| 118 | mustGet(t, a, "/s/"+token, http.StatusNotFound, nil) |
| 119 | |
| 120 | // The row stays, so the page can still say the link existed and |
| 121 | // that it is off. |
| 122 | after := mustGet(t, a, path+"/share", http.StatusOK, cookies).Body.String() |
| 123 | if !strings.Contains(after, "Revoked") { |
| 124 | t.Errorf("the share page does not say the link is revoked:\n%s", after) |
| 125 | } |
| 126 | |
| 127 | // Revoking twice is what a double-clicked button does, and the URL |
| 128 | // is off either way: it is not an error page. |
| 129 | if rec := post(t, a, path+"/share/"+m[1]+"/revoke", nil, cookies...); rec.Code != http.StatusSeeOther { |
| 130 | t.Errorf("revoking twice: status %d, want 303", rec.Code) |
| 131 | } |
| 132 | // An id that is not this deck's link is a row that does not exist. |
| 133 | if rec := post(t, a, path+"/share/99999/revoke", nil, cookies...); rec.Code != http.StatusNotFound { |
| 134 | t.Errorf("revoking an unknown link: status %d, want 404", rec.Code) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // TestShareLinkStoresOnlyHash: the table holds a fingerprint, never the |
| 139 | // URL. A plaintext token here would be a live, unauthenticated read |
| 140 | // capability sitting in a file that gets backed up. |
| 141 | func TestShareLinkStoresOnlyHash(t *testing.T) { |
| 142 | a := newApp(t) |
| 143 | cookies := signedIn(t, a, allowedAddress) |
| 144 | path := createDeck(t, a, cookies, "Board update") |
| 145 | |
| 146 | token := mintShareLink(t, a, path, cookies) |
| 147 | |
| 148 | // Every column of the row concatenated, so this is an assertion |
| 149 | // about the whole row rather than about the one column that was |
| 150 | // remembered to be checked. |
| 151 | var whole []string |
| 152 | err := a.db.G.Raw(`SELECT COALESCE(id,'') || COALESCE(deck_id,'') || COALESCE(token_hash,'') || |
| 153 | COALESCE(actor,'') || COALESCE(views,'') || COALESCE(created_at,'') || COALESCE(revoked_at,'') |
| 154 | FROM share_links`).Scan(&whole).Error |
| 155 | if err != nil { |
| 156 | t.Fatalf("read share_links: %v", err) |
| 157 | } |
| 158 | if len(whole) != 1 { |
| 159 | t.Fatalf("share_links holds %d rows, want 1", len(whole)) |
| 160 | } |
| 161 | if strings.Contains(whole[0], token) { |
| 162 | t.Errorf("share_links holds the plaintext token: %s", whole[0]) |
| 163 | } |
| 164 | |
| 165 | var stored struct { |
| 166 | TokenHash string |
| 167 | Actor string |
| 168 | } |
| 169 | if err := a.db.G.Raw("SELECT token_hash, actor FROM share_links").Scan(&stored).Error; err != nil { |
| 170 | t.Fatalf("read share_links: %v", err) |
| 171 | } |
| 172 | sum := sha256.Sum256([]byte(token)) |
| 173 | if want := hex.EncodeToString(sum[:]); stored.TokenHash != want { |
| 174 | t.Errorf("share_links.token_hash = %q, want the SHA-256 hex %s", stored.TokenHash, want) |
| 175 | } |
| 176 | if stored.Actor == "" { |
| 177 | t.Errorf("share_links.actor is empty: every write records an actor") |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | // TestShareLinkHidesNotes: a share link is read-only and it is not a |
| 182 | // way into the speaker notes, the settings or the share page itself. |
| 183 | func TestShareLinkHidesNotes(t *testing.T) { |
| 184 | a := newApp(t) |
| 185 | cookies := signedIn(t, a, allowedAddress) |
| 186 | path := sampleDeck(t, a, cookies) |
| 187 | |
| 188 | // The same deck, signed in, does show them — otherwise this test |
| 189 | // would pass over a viewer that renders no notes to anybody. |
| 190 | signedInPage := mustGet(t, a, path+"?slide=2", http.StatusOK, cookies).Body.String() |
| 191 | if !strings.Contains(signedInPage, "template. Text arrives as a small Markdown subset") { |
| 192 | t.Fatalf("the signed-in viewer shows no notes to compare against:\n%s", signedInPage) |
| 193 | } |
| 194 | |
| 195 | token := mintShareLink(t, a, path, cookies) |
| 196 | page := mustGet(t, a, "/s/"+token+"?slide=2", http.StatusOK, nil).Body.String() |
| 197 | |
| 198 | if strings.Contains(page, "template. Text arrives as a small Markdown subset") { |
| 199 | t.Errorf("the share link leaks the speaker notes:\n%s", page) |
| 200 | } |
| 201 | if strings.Contains(page, "Speaker notes") { |
| 202 | t.Errorf("the share link offers the notes:\n%s", page) |
| 203 | } |
| 204 | if strings.Contains(page, "/settings") || strings.Contains(page, "/share") { |
| 205 | t.Errorf("the share link offers the owner's screens:\n%s", page) |
| 206 | } |
| 207 | if strings.Contains(page, "/notes") { |
| 208 | t.Errorf("the share link offers the notes page:\n%s", page) |
| 209 | } |
| 210 | // The deck itself is still not readable without the link: a |
| 211 | // signed-out request for it is sent to sign in. |
| 212 | mustGet(t, a, path, http.StatusSeeOther, nil) |
| 213 | } |
| 214 | |
| 215 | // TestShareIsTheOwnersAlone: a team member can read the deck and still |
| 216 | // cannot mint, list or revoke a link on it. |
| 217 | func TestShareIsTheOwnersAlone(t *testing.T) { |
| 218 | a := newSharedApp(t) |
| 219 | mine := signedIn(t, a, allowedAddress) |
| 220 | theirs := signedIn(t, a, otherAddress) |
| 221 | |
| 222 | path := createDeck(t, a, mine, "All hands") |
| 223 | if rec := post(t, a, path+"/settings", url.Values{ |
| 224 | "title": {"All hands"}, "theme": {"ink"}, "rung": {"team"}, |
| 225 | }, mine...); rec.Code != http.StatusSeeOther { |
| 226 | t.Fatalf("raising the rung: status %d\n%s", rec.Code, rec.Body) |
| 227 | } |
| 228 | mustGet(t, a, path, http.StatusOK, theirs) |
| 229 | |
| 230 | mustGet(t, a, path+"/share", http.StatusNotFound, theirs) |
| 231 | if rec := post(t, a, path+"/share", nil, theirs...); rec.Code != http.StatusNotFound { |
| 232 | t.Errorf("POST %s/share as a member: status %d, want 404", path, rec.Code) |
| 233 | } |
| 234 | |
| 235 | token := mintShareLink(t, a, path, mine) |
| 236 | page := mustGet(t, a, path+"/share", http.StatusOK, mine).Body.String() |
| 237 | m := revokeAction.FindStringSubmatch(page) |
| 238 | if m == nil { |
| 239 | t.Fatalf("no revoke form:\n%s", page) |
| 240 | } |
| 241 | if rec := post(t, a, path+"/share/"+m[1]+"/revoke", nil, theirs...); rec.Code != http.StatusNotFound { |
| 242 | t.Errorf("revoke as a member: status %d, want 404", rec.Code) |
| 243 | } |
| 244 | mustGet(t, a, "/s/"+token, http.StatusOK, nil) |
| 245 | } |
| 246 | |