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 "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.
16var newToken = regexp.MustCompile(`\?new=([A-Za-z0-9_-]{43})`)
17
18// revokeAction finds a link row's revoke form on the share page.
19var 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.
23func 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.
40func 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.
101func 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.
141func 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.
183func 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.
217func 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