oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "strings"
8 "testing"
9)
10
11// TestEmailedLinkOpensTheConfirmDoor walks the magic-link sign-in end to
12// end: begin, the mail, GET of the emailed link (the confirm door, which
13// spends nothing), the door's POST, and a session that opens the list.
14//
15// GET /auth/verify used to sign in on sight, so a mail-security gateway
16// that fetches every link in an inbound message (Safe Links, Proofpoint)
17// spent it before the person clicked, and they were told it had expired.
18// Rastrillo now confirms on GET and redeems on POST; this pins that the
19// confirm step is Sheets' own door with one Sign in button, not
20// rastrillo's plain English fallback, and that the POST is mounted.
21func TestEmailedLinkOpensTheConfirmDoor(t *testing.T) {
22 app, mail := appWithRecorder(t)
23 h := app.Router()
24
25 if w := postSignin(t, app, signedInAddress); w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/signin?sent=1" {
26 t.Fatalf("POST /signin = %d %q, want 303 /signin?sent=1", w.Code, w.Header().Get("Location"))
27 }
28 link := mail.lastLink(t)
29 u, err := url.Parse(link)
30 if err != nil {
31 t.Fatal(err)
32 }
33
34 // Opening it, twice, as a scanner and then the person would.
35 for _, who := range []string{"scanner", "person"} {
36 rec := httptest.NewRecorder()
37 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, u.RequestURI(), nil))
38 if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 {
39 t.Fatalf("%s's GET = %d with %d cookies, want 200 and nothing minted", who, rec.Code, len(rec.Result().Cookies()))
40 }
41 body := rec.Body.String()
42 for _, want := range []string{"estilo-door", "estilo-signin-form", `alt=""`, "<span>Sheets</span>", "<title>Sign in · Sheets</title>", `name="token" value="` + u.Query().Get("token") + `"`} {
43 if !strings.Contains(body, want) {
44 t.Errorf("%s: confirm door lacks %q:\n%s", who, want, body)
45 }
46 }
47 if n := strings.Count(body, "<button"); n != 1 || !strings.Contains(body, `type="submit" autofocus>Sign in</button>`) {
48 t.Errorf("%s: confirm door has %d buttons, want exactly one Sign in:\n%s", who, n, body)
49 }
50 if strings.Contains(body, `name="address"`) {
51 t.Errorf("%s: confirm door asks for an address again", who)
52 }
53 if strings.Contains(body, "<title>Sign in to ") {
54 t.Errorf("%s: rastrillo's fallback confirm page, not the door", who)
55 }
56 // auth sets these before RenderConfirm; render must not undo them.
57 for header, want := range map[string]string{
58 "Cache-Control": "no-store",
59 "Referrer-Policy": "strict-origin",
60 "X-Frame-Options": "DENY",
61 } {
62 if got := rec.Header().Get(header); got != want {
63 t.Errorf("%s: %s = %q, want %q", who, header, got, want)
64 }
65 }
66 }
67
68 // A post without a browser's same-origin evidence is refused and
69 // spends nothing.
70 action, token := openLink(t, h, link)
71 cross := httptest.NewRequest(http.MethodPost, action, strings.NewReader(url.Values{"token": {token}}.Encode()))
72 cross.Header.Set("Content-Type", "application/x-www-form-urlencoded")
73 cross.Header.Set("Sec-Fetch-Site", "cross-site")
74 if rec := do(t, app, cross); rec.Code != http.StatusForbidden || len(rec.Result().Cookies()) != 0 {
75 t.Fatalf("cross-site POST = %d with %d cookies, want 403 and nothing minted", rec.Code, len(rec.Result().Cookies()))
76 }
77
78 // Sign in: the POST spends the link and mints the session.
79 signed := postLink(t, h, action, token)
80 if signed.Code != http.StatusSeeOther || signed.Header().Get("Location") != "/sheets" {
81 t.Fatalf("POST %s = %d %q, want 303 /sheets", action, signed.Code, signed.Header().Get("Location"))
82 }
83 cookies := signed.Result().Cookies()
84 list := page(t, app, cookies, "/sheets")
85 if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), signedInAddress) {
86 t.Fatalf("GET /sheets with the new session = %d, or it does not name %s", list.Code, signedInAddress)
87 }
88
89 // Spent: the door still opens (it looks nothing up), but its button
90 // now says the link expired.
91 action, token = openLink(t, h, link)
92 again := postLink(t, h, action, token)
93 if len(again.Result().Cookies()) != 0 || again.Header().Get("Location") != "/signin?err=expired" {
94 t.Fatalf("second POST = %d %q, want /signin?err=expired and no session", again.Code, again.Header().Get("Location"))
95 }
96}
97
98// TestALinkRefusedAtRedemptionIsTheDoor: an address admitted when its
99// link was mailed but not when it is redeemed — a guest whose only grant
100// was revoked in between — gets the sign-in door with the refusal on it,
101// in auth's 403 (auth.Config.Refused, App.refused), and no session.
102func TestALinkRefusedAtRedemptionIsTheDoor(t *testing.T) {
103 app, cookies := signIn(t)
104 s, _ := newSheet(t, app, cookies, "Budget")
105 guest, err := app.grant(s.ID, "ada@elsewhere.example", SheetViewer, signedInMember(t, app))
106 if err != nil {
107 t.Fatal(err)
108 }
109 mail := app.testMailer(t)
110 postSignin(t, app, "ada@elsewhere.example")
111 link := mail.lastLink(t)
112 if err := app.revoke(s.ID, guest.ID); err != nil {
113 t.Fatal(err)
114 }
115
116 rec := followLink(t, app.Router(), link)
117 if rec.Code != http.StatusForbidden || rec.Header().Get("Location") != "" {
118 t.Fatalf("refused redemption = %d %q, want 403 and the door itself", rec.Code, rec.Header().Get("Location"))
119 }
120 for _, c := range rec.Result().Cookies() {
121 if strings.Contains(c.Name, "session") && c.MaxAge >= 0 && c.Value != "" {
122 t.Fatalf("a refused sign-in set a session cookie %q", c.Name)
123 }
124 }
125 body := rec.Body.String()
126 for _, want := range []string{"estilo-door", `name="address"`, `action="/signin"`, "can’t sign in to Sheets"} {
127 if !strings.Contains(body, want) {
128 t.Errorf("refusal door lacks %q:\n%s", want, body)
129 }
130 }
131}
132