oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1package callstest
2
3import (
4 "net/http"
5 "net/url"
6 "strconv"
7 "strings"
8 "testing"
9 "time"
10)
11
12// memberWithMeeting's harness carries a second member, paul@rslw.com,
13// unused by most callers, for the signal tests that need two members in
14// one room (newMemberBrowser).
15func memberWithMeeting(t *testing.T) (*harness, string, string) {
16 t.Helper()
17 x := newHarness(t, "paul@example.com", "paul@rslw.com")
18 c := x.browser(t)
19 c.signIn("paul@example.com")
20 sqid, tok := createMeeting(t, c, "Board meeting", "")
21 return x, sqid, tok
22}
23
24// Spec §3.2's one 404: unknown, revoked and ended are not merely all
25// 404, they are the same 404 — the same status and the same bytes, so
26// the response tells an outsider nothing about which it was.
27func TestUnknownRevokedAndEndedLinksAreOne404(t *testing.T) {
28 x, replaced, revoked := memberWithMeeting(t)
29 c := x.browser(t)
30 c.signIn("paul@example.com")
31 if rec := c.post("/meetings/"+replaced+"/links", nil); rec.Code != http.StatusOK {
32 t.Fatalf("replace link: %d", rec.Code)
33 }
34 ended, endedTok := createMeeting(t, c, "over", "")
35 if rec := c.post("/meetings/"+ended+"/end", nil); rec.Code != http.StatusSeeOther {
36 t.Fatalf("end meeting: %d", rec.Code)
37 }
38
39 bodies := map[string]string{}
40 for _, tc := range []struct{ name, token string }{
41 {"unknown", strings.Repeat("A", 43)},
42 {"revoked", revoked},
43 {"ended", endedTok},
44 } {
45 rec := x.browser(t).get("/j/" + tc.token)
46 if rec.Code != http.StatusNotFound {
47 t.Fatalf("%s: %d, want 404", tc.name, rec.Code)
48 }
49 bodies[tc.name] = rec.Body.String()
50 }
51 if bodies["revoked"] != bodies["unknown"] || bodies["ended"] != bodies["unknown"] {
52 t.Fatalf("the three 404s differ:\nunknown: %q\nrevoked: %q\nended: %q",
53 bodies["unknown"], bodies["revoked"], bodies["ended"])
54 }
55}
56
57func TestSignedOutOutsiderSignsInThroughTheLinkInTheSameBrowser(t *testing.T) {
58 x, sqid, tok := memberWithMeeting(t)
59 member := x.browser(t)
60 member.signIn("paul@example.com")
61 member.post("/meetings/"+sqid+"/settings", url.Values{"require_signin": {"on"}})
62 g := x.browser(t)
63 rec := g.get("/j/" + tok)
64 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin" {
65 t.Fatalf("follow signed out: %d %s", rec.Code, rec.Header().Get("Location"))
66 }
67 if _, ok := g.cookies["calls_join"]; !ok {
68 t.Fatal("no pending-join cookie")
69 }
70 if dest := g.signIn("guest@example.com"); dest != "/m/"+sqid+"/join" {
71 t.Fatalf("outsider landed on %s", dest)
72 }
73 if rec := g.get("/m/" + sqid + "/join"); rec.Code != http.StatusOK {
74 t.Fatalf("holding page as guest: %d", rec.Code)
75 }
76 if rec := g.get("/"); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/welcome" {
77 t.Fatal("guest can see the meeting list")
78 }
79}
80
81func TestEmailedLinkWorksInAnotherBrowser(t *testing.T) {
82 x, sqid, tok := memberWithMeeting(t)
83 phone := x.browser(t)
84 phone.get("/j/" + tok)
85 rec := phone.post("/signin", url.Values{"address": {"guest@example.com"}})
86 if rec.Header().Get("Location") != "/signin?sent=1" {
87 t.Fatal("outsider with cookie refused")
88 }
89 _, link := x.mail.last()
90 laptop := x.browser(t) // no cookie here
91 if dest := laptop.follow(link); dest != "/m/"+sqid+"/join" {
92 t.Fatalf("other-browser click landed on %s", dest)
93 }
94}
95
96func TestLinkReplacedBetweenMailAndClickAdmitsNobody(t *testing.T) {
97 x, sqid, tok := memberWithMeeting(t)
98 g := x.browser(t)
99 g.get("/j/" + tok)
100 g.post("/signin", url.Values{"address": {"guest@example.com"}})
101 _, link := x.mail.last()
102 member := x.browser(t)
103 member.signIn("paul@example.com")
104 member.post("/meetings/"+sqid+"/links", nil)
105 // Authorize refuses it, and the refusal is the door rendered in a
106 // 403 by auth's Refused hook, not rastrillo's bare text.
107 rec := g.redeem(link)
108 if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "can’t sign in to Calls") {
109 t.Fatalf("outsider's stale click: %d, want Authorize's refusal as the door in a 403", rec.Code)
110 }
111 if rec := g.get("/m/" + sqid + "/join"); rec.Code == http.StatusOK {
112 t.Fatal("a grant was minted through a replaced link")
113 }
114}
115
116func TestSignedOutMemberFollowsALinkAndLandsOnItsHoldingPage(t *testing.T) {
117 x, sqid, tok := memberWithMeeting(t)
118 m := x.browser(t)
119 m.get("/j/" + tok)
120 if dest := m.signIn("paul@example.com"); dest != "/m/"+sqid+"/join" {
121 t.Fatalf("member landed on %s", dest)
122 }
123}
124
125func TestOutsiderWithoutCookieStillGetsNoMail(t *testing.T) {
126 x, _, _ := memberWithMeeting(t)
127 before := x.mail.count()
128 x.browser(t).post("/signin", url.Values{"address": {"guest@example.com"}})
129 if x.mail.count() != before {
130 t.Fatal("mail sent to an outsider with no link")
131 }
132}
133
134func TestOutsiderBudgetsStopMailAtFivePerLinkAndTenOverall(t *testing.T) {
135 x, _, tok := memberWithMeeting(t)
136 before := x.mail.count()
137 for i := 0; i < 7; i++ {
138 g := x.browser(t)
139 g.get("/j/" + tok)
140 g.post("/signin", url.Values{"address": {"g" + string(rune('a'+i)) + "@example.com"}})
141 }
142 if got := x.mail.count() - before; got != 5 {
143 t.Fatalf("mails through one link: %d, want 5", got)
144 }
145 x.advance(16 * time.Minute)
146 c := x.browser(t)
147 c.signIn("paul@example.com")
148 var toks []string
149 for i := 0; i < 3; i++ {
150 _, t2 := createMeeting(t, c, "m", "")
151 toks = append(toks, t2)
152 }
153 before = x.mail.count()
154 for i := 0; i < 12; i++ {
155 g := x.browser(t)
156 g.get("/j/" + toks[i%3])
157 g.post("/signin", url.Values{"address": {"h" + string(rune('a'+i)) + "@example.com"}})
158 }
159 if got := x.mail.count() - before; got != 10 {
160 t.Fatalf("mails across links: %d, want 10", got)
161 }
162}
163
164func TestMemberAddressExhaustsTheSharedLimiterWithTheSameAnswer(t *testing.T) {
165 x := newHarness(t, "paul@example.com")
166 for i := 0; i < 5; i++ {
167 g := x.browser(t)
168 rec := g.post("/signin", url.Values{"address": {"paul@example.com"}})
169 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
170 t.Fatalf("attempt %d: %d %s, want 303 /signin?sent=1", i, rec.Code, rec.Header().Get("Location"))
171 }
172 }
173 g := x.browser(t)
174 rec := g.post("/signin", url.Values{"address": {"paul@example.com"}})
175 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?err=rate" {
176 t.Fatalf("sixth attempt: %d %s, want 303 /signin?err=rate", rec.Code, rec.Header().Get("Location"))
177 }
178 if got := x.mail.count(); got != 5 {
179 t.Fatalf("mail count: %d, want 5", got)
180 }
181 if body := g.get("/signin?err=rate").Body.String(); !strings.Contains(body, "Too many attempts. Wait a few minutes, then try again.") {
182 t.Fatal("?err=rate does not render rastrillo's rate-limit message")
183 }
184}
185
186// Review C1-8: the twenty-per-client allowance, not the five-per-address
187// one. rastrillo's limiter is keyed on RemoteAddr, which
188// httptest.NewRequest fixes at 192.0.2.1:1234, so every harness client
189// shares one budget. Twenty-one different member addresses therefore
190// exhaust it even though no address is near its own five.
191//
192// This is spec §3.2's filed residue, not a quirk of the harness. Behind
193// the platform edge, RemoteAddr is the proxy, so every visitor of every
194// team shares this one budget: twenty sign-in attempts per fifteen
195// minutes for the whole world, exhaustible on purpose by anyone, and
196// nothing in front of Begin can tell one client from another. The fix is
197// a trusted client address from the edge or a per-app limiter keyed on
198// something better; the question is filed on the platform.
199func TestSharedClientAllowanceIsExhaustedByOneClient(t *testing.T) {
200 const clientBudget = 20 // signin.Flow.IPBudget's default
201 members := make([]string, 0, clientBudget+1)
202 for i := 0; i <= clientBudget; i++ {
203 members = append(members, "member"+strconv.Itoa(i)+"@example.com")
204 }
205 x := newHarness(t, members...)
206
207 for i := 0; i < clientBudget; i++ {
208 rec := x.browser(t).post("/signin", url.Values{"address": {members[i]}})
209 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
210 t.Fatalf("attempt %d: %d %s, want 303 /signin?sent=1", i+1, rec.Code, rec.Header().Get("Location"))
211 }
212 }
213 rec := x.browser(t).post("/signin", url.Values{"address": {members[clientBudget]}})
214 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?err=rate" {
215 t.Fatalf("attempt %d: %d %s, want 303 /signin?err=rate",
216 clientBudget+1, rec.Code, rec.Header().Get("Location"))
217 }
218 if got := x.mail.count(); got != clientBudget {
219 t.Fatalf("mails sent: %d, want %d", got, clientBudget)
220 }
221}
222
223// Spec §3.2: a member who followed a link while signed out reaches the
224// holding screen in whichever browser the emailed link is opened — by
225// the same pending row an outsider uses. The existing member test opens
226// the link in the browser that asked for it (review C1-8).
227func TestSignedOutMemberCompletesInAnotherBrowser(t *testing.T) {
228 x, sqid, tok := memberWithMeeting(t)
229 desktop := x.browser(t)
230 desktop.get("/j/" + tok)
231 rec := desktop.post("/signin", url.Values{"address": {"paul@example.com"}})
232 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
233 t.Fatalf("member's request: %d %s", rec.Code, rec.Header().Get("Location"))
234 }
235 to, link := x.mail.last()
236 if to != "paul@example.com" || link == "" {
237 t.Fatalf("no magic link mailed to the member (last to %q)", to)
238 }
239 phone := x.browser(t) // no session, no pending-join cookie
240 if dest := phone.follow(link); dest != "/m/"+sqid+"/join" {
241 t.Fatalf("other-browser click landed on %s, want the holding page", dest)
242 }
243 if rec := phone.get("/m/" + sqid + "/join"); rec.Code != http.StatusOK {
244 t.Fatalf("holding page in the second browser: %d", rec.Code)
245 }
246}
247
248// Spec §3.2's two refusals: a signed-in person whose pending row fails
249// the /continue re-check gets / or /welcome, and neither path mints a
250// grant. The old test could only observe the redirect; this one asserts
251// the absence of the grant by asking for the holding page (review C1-8).
252func TestReplacedLinkFallbackForSignedInPerson(t *testing.T) {
253 t.Run("member lands on the list", func(t *testing.T) {
254 x, sqid, tok := memberWithMeeting(t)
255 owner := x.browser(t)
256 owner.signIn("paul@example.com")
257
258 // A pending row written after the session exists, from another
259 // browser, so signing in did not already consume it.
260 other := x.browser(t)
261 other.get("/j/" + tok)
262 if rec := other.post("/signin", url.Values{"address": {"paul@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" {
263 t.Fatalf("member's request: %s", rec.Header().Get("Location"))
264 }
265 if rec := owner.post("/meetings/"+sqid+"/links", nil); rec.Code != http.StatusOK {
266 t.Fatalf("replace link: %d", rec.Code)
267 }
268 rec := owner.get("/continue")
269 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
270 t.Fatalf("member after a replaced link: %d %s, want 303 /", rec.Code, rec.Header().Get("Location"))
271 }
272 })
273
274 t.Run("non-member lands on welcome with no grant", func(t *testing.T) {
275 x, first, firstTok := memberWithMeeting(t)
276 guest := x.browser(t)
277 guest.get("/j/" + firstTok)
278 guest.signIn("guest@example.com") // signed in, and granted the first meeting
279
280 owner := x.browser(t)
281 owner.signIn("paul@example.com")
282 second, secondTok := createMeeting(t, owner, "second", "")
283
284 // The pending row for the second meeting, from a browser with no
285 // session, so the guest's own session is untouched.
286 other := x.browser(t)
287 other.get("/j/" + secondTok)
288 if rec := other.post("/signin", url.Values{"address": {"guest@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" {
289 t.Fatalf("outsider's request: %s", rec.Header().Get("Location"))
290 }
291 if rec := owner.post("/meetings/"+second+"/links", nil); rec.Code != http.StatusOK {
292 t.Fatalf("replace link: %d", rec.Code)
293 }
294
295 rec := guest.get("/continue")
296 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/welcome" {
297 t.Fatalf("non-member after a replaced link: %d %s, want 303 /welcome", rec.Code, rec.Header().Get("Location"))
298 }
299 if rec := guest.get("/m/" + second + "/join"); rec.Code != http.StatusNotFound {
300 t.Fatalf("holding page of the second meeting: %d, want 404 — a grant was minted", rec.Code)
301 }
302 // The grant they legitimately hold is untouched.
303 if rec := guest.get("/m/" + first + "/join"); rec.Code != http.StatusOK {
304 t.Fatalf("holding page of the first meeting: %d, want 200", rec.Code)
305 }
306 })
307
308 // Spec §3.2's third continuation case for a non-member: the pending
309 // row is not replaced but simply expired. An outsider whose row
310 // expires before they click is refused at Authorize with a 403 and
311 // never reaches /continue (TestExpiredPendingRowAdmitsNobody), so the
312 // only way to exercise this branch is with a session that already
313 // exists — which is the gate the review found missing (C1-8).
314 t.Run("non-member with an expired row lands on welcome with no grant", func(t *testing.T) {
315 x, first, firstTok := memberWithMeeting(t)
316 guest := x.browser(t)
317 guest.get("/j/" + firstTok)
318 guest.signIn("guest@example.com")
319
320 owner := x.browser(t)
321 owner.signIn("paul@example.com")
322 second, secondTok := createMeeting(t, owner, "second", "")
323
324 other := x.browser(t)
325 other.get("/j/" + secondTok)
326 if rec := other.post("/signin", url.Values{"address": {"guest@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" {
327 t.Fatalf("outsider's request: %s", rec.Header().Get("Location"))
328 }
329 // "?sent=1" alone is also the answer to a refusal (spec §3.2's
330 // same-answer rule) — confirm the pending row this subtest means
331 // to expire was actually written, not just that the request was
332 // accepted-looking (Codex round 3, C3-3).
333 if n := x.pendingJoins(t); n != 1 {
334 t.Fatalf("pending_joins rows after the outsider's second POST /signin: %d, want 1", n)
335 }
336
337 // The link stays live; only the row runs out (fifteen minutes).
338 x.advance(16 * time.Minute)
339
340 rec := guest.get("/continue")
341 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/welcome" {
342 t.Fatalf("non-member with an expired row: %d %s, want 303 /welcome", rec.Code, rec.Header().Get("Location"))
343 }
344 if rec := guest.get("/m/" + second + "/join"); rec.Code != http.StatusNotFound {
345 t.Fatalf("holding page of the second meeting: %d, want 404 — a grant was minted", rec.Code)
346 }
347 if rec := guest.get("/m/" + first + "/join"); rec.Code != http.StatusOK {
348 t.Fatalf("holding page of the first meeting: %d, want 200", rec.Code)
349 }
350 })
351}
352
353// Review follow-up to C1-7: the point of the sweep is that an unclicked
354// magic link does not keep a plaintext address in rastrillo's auth_links
355// for the life of the instance. Asserted on the row itself.
356//
357// Eleven minutes is not enough: signin.Flow.LinkTTL defaults to fifteen
358// (keymaildev/signin@v0.1.1/flow.go:186) and rastrillo sets no override,
359// so a sweep at +11 must keep the row and one at +16 must take it. Both
360// halves are asserted, because a sweep that deleted everything would
361// pass the second on its own.
362func TestSweepClearsUnclickedMagicLinks(t *testing.T) {
363 x := newHarness(t, "paul@example.com")
364 if rec := x.browser(t).post("/signin", url.Values{"address": {"paul@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" {
365 t.Fatalf("sign-in request: %s", rec.Header().Get("Location"))
366 }
367 if x.authLinks(t) != 1 {
368 t.Fatalf("auth_links rows after a sign-in request: %d, want 1", x.authLinks(t))
369 }
370
371 x.advance(11 * time.Minute)
372 x.sweep()
373 if n := x.authLinks(t); n != 1 {
374 t.Fatalf("auth_links rows after a sweep inside the link's life: %d, want 1", n)
375 }
376
377 x.advance(5 * time.Minute)
378 x.sweep()
379 if n := x.authLinks(t); n != 0 {
380 t.Fatalf("auth_links rows after the link expired and was swept: %d, want 0", n)
381 }
382}
383
384func TestExpiredPendingRowAdmitsNobody(t *testing.T) {
385 x, _, tok := memberWithMeeting(t)
386 g := x.browser(t)
387 g.get("/j/" + tok)
388 rec := g.post("/signin", url.Values{"address": {"guest@example.com"}})
389 if rec.Header().Get("Location") != "/signin?sent=1" {
390 t.Fatalf("outsider's request: %d %s", rec.Code, rec.Header().Get("Location"))
391 }
392 before := x.mail.count()
393 _, link := x.mail.last()
394 x.advance(16 * time.Minute)
395 if rec := g.redeem(link); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "can’t sign in to Calls") {
396 t.Fatalf("expired pending row click: %d, want Authorize's refusal as the door in a 403", rec.Code)
397 }
398 if got := x.mail.count(); got != before {
399 t.Fatalf("mail count changed after the expired click: %d, want %d", got, before)
400 }
401
402 x2, _, tok2 := memberWithMeeting(t)
403 m := x2.browser(t)
404 m.get("/j/" + tok2)
405 rec = m.post("/signin", url.Values{"address": {"paul@example.com"}})
406 if rec.Header().Get("Location") != "/signin?sent=1" {
407 t.Fatalf("member's request: %d %s", rec.Code, rec.Header().Get("Location"))
408 }
409 _, link2 := x2.mail.last()
410 x2.advance(16 * time.Minute)
411 if dest := m.follow(link2); dest != "/" {
412 t.Fatalf("signed-out member after an expired pending row landed on %s, want /", dest)
413 }
414}
415
416func TestExpiredGrantIsRefusedEntry(t *testing.T) {
417 x, sqid, tok := memberWithMeeting(t)
418 g := x.browser(t)
419 g.get("/j/" + tok)
420 g.signIn("guest@example.com")
421 x.advance(2*time.Hour + time.Minute)
422 if rec := g.get("/m/" + sqid + "/join"); rec.Code != http.StatusNotFound {
423 t.Fatalf("expired guest at the holding page: %d, want 404", rec.Code)
424 }
425}
426
427// TestPendingRowExpiryIsByInstantWhateverTheClocksZone: pending_joins'
428// expires_at is compared in SQL against a UTC parameter (pendingLive's
429// "expires_at > ?" and the sweep's "expires_at <= ?"), so it agrees with
430// the instants only while every row is stored in one zone. rastrillo
431// fb806a2 opens the database with _timezone=UTC, which converts the
432// store's own clock (time.Now, in the machine's zone) on the way in.
433// Before that, a machine nine hours ahead of UTC stored a row that read
434// as nine hours later than it was: never swept and live at Authorize
435// long after its fifteen minutes. Both directions are checked.
436func TestPendingRowExpiryIsByInstantWhateverTheClocksZone(t *testing.T) {
437 for _, offset := range []int{9, -9} {
438 t.Run("UTC"+strconv.FormatInt(int64(offset), 10), func(t *testing.T) {
439 x, _, tok := memberWithMeeting(t)
440 x.now = x.now.In(time.FixedZone("", offset*3600))
441 x.advance(0)
442 g := x.browser(t)
443 g.get("/j/" + tok)
444 if rec := g.post("/signin", url.Values{"address": {"guest@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" {
445 t.Fatalf("outsider's request: %s", rec.Header().Get("Location"))
446 }
447 if n := x.pendingJoins(t); n != 1 {
448 t.Fatalf("pending_joins rows: %d, want 1", n)
449 }
450 x.advance(14 * time.Minute)
451 x.sweep()
452 if n := x.pendingJoins(t); n != 1 {
453 t.Fatalf("a sweep inside the row's fifteen minutes took it")
454 }
455 x.advance(2 * time.Minute)
456 x.sweep()
457 if n := x.pendingJoins(t); n != 0 {
458 t.Fatalf("a sweep after the row's fifteen minutes left %d", n)
459 }
460 })
461 }
462}
463