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 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "strings"
8 "testing"
9
10 slides "amadan.net/oficina/slides/internal/slides"
11)
12
13// mailedLink asks for a link for address and returns the one emailed.
14func mailedLink(t *testing.T, a *testApp, address string) string {
15 t.Helper()
16 before := len(a.mailer.messages())
17 if rec := post(t, a, "/signin", url.Values{"address": {address}}); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
18 t.Fatalf("POST /signin = %d %q, want 303 /signin?sent=1", rec.Code, rec.Header().Get("Location"))
19 }
20 msgs := a.mailer.messages()
21 if len(msgs) != before+1 {
22 t.Fatalf("POST /signin for %q mailed %d messages, want 1", address, len(msgs)-before)
23 }
24 link := magicLinkPattern.FindString(msgs[len(msgs)-1].Body)
25 if link == "" {
26 t.Fatalf("no magic link in the mailed body:\n%s", msgs[len(msgs)-1].Body)
27 }
28 return link
29}
30
31// sessionCookie reports whether rec set a live session cookie.
32func sessionCookie(rec *httptest.ResponseRecorder) bool {
33 for _, c := range rec.Result().Cookies() {
34 if strings.Contains(c.Name, "session") && c.Value != "" && c.MaxAge >= 0 {
35 return true
36 }
37 }
38 return false
39}
40
41// TestEmailedLinkOpensTheConfirmDoor walks the magic-link sign-in end to
42// end: begin, the mail, GET of the emailed link (the confirm door, which
43// spends nothing), the door's POST, and a session that opens the deck
44// list.
45//
46// GET /auth/verify used to sign in on sight, so a mail-security gateway
47// that fetches every link in an inbound message (Safe Links, Proofpoint)
48// spent it before the person clicked, and they were told it had expired.
49// rastrillo now confirms on GET and redeems on POST (#64); this pins that
50// the confirm step is Slides' own door with one Sign in button, not
51// rastrillo's plain English fallback, and that the POST is mounted.
52func TestEmailedLinkOpensTheConfirmDoor(t *testing.T) {
53 a := newApp(t)
54 link := mailedLink(t, a, allowedAddress)
55 u, err := url.Parse(link)
56 if err != nil {
57 t.Fatal(err)
58 }
59
60 // Opening it, twice, as a scanner and then the person would.
61 for _, who := range []string{"scanner", "person"} {
62 rec := get(t, a, u.RequestURI())
63 if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 {
64 t.Fatalf("%s's GET = %d with %d cookies, want 200 and nothing minted", who, rec.Code, len(rec.Result().Cookies()))
65 }
66 body := rec.Body.String()
67 for _, want := range []string{
68 "estilo-door", "estilo-signin-form", "<span>Slides</span>",
69 "Select Sign in to finish signing in.",
70 `action="/auth/verify"`,
71 `name="token" value="` + u.Query().Get("token") + `"`,
72 } {
73 if !strings.Contains(body, want) {
74 t.Errorf("%s: confirm door lacks %q:\n%s", who, want, body)
75 }
76 }
77 if n := strings.Count(body, "<button"); n != 1 || !strings.Contains(body, `type="submit" autofocus>Sign in</button>`) {
78 t.Errorf("%s: confirm door has %d buttons, want exactly one Sign in:\n%s", who, n, body)
79 }
80 if strings.Contains(body, `name="address"`) {
81 t.Errorf("%s: confirm door asks for an address again", who)
82 }
83 if strings.Contains(body, "slides.signin.") {
84 t.Errorf("%s: a catalog key leaked onto the confirm door", who)
85 }
86 if strings.Contains(body, "<title>Sign in to ") {
87 t.Errorf("%s: rastrillo's fallback confirm page, not the door", who)
88 }
89 // rauth sets these before RenderConfirm; render must not undo them.
90 for header, want := range map[string]string{
91 "Cache-Control": "no-store",
92 "Referrer-Policy": "strict-origin",
93 "X-Frame-Options": "DENY",
94 } {
95 if got := rec.Header().Get(header); got != want {
96 t.Errorf("%s: %s = %q, want %q", who, header, got, want)
97 }
98 }
99 }
100
101 // A cross-site post is refused and spends nothing: the proper POST
102 // below still signs in with the same token.
103 action, token := openLink(t, a, link)
104 cross := httptest.NewRequest(http.MethodPost, action, strings.NewReader(url.Values{"token": {token}}.Encode()))
105 cross.Header.Set("Content-Type", "application/x-www-form-urlencoded")
106 cross.Header.Set("Sec-Fetch-Site", "cross-site")
107 refused := httptest.NewRecorder()
108 a.ServeHTTP(refused, cross)
109 if refused.Code != http.StatusForbidden || len(refused.Result().Cookies()) != 0 {
110 t.Fatalf("cross-site POST = %d with %d cookies, want 403 and nothing minted", refused.Code, len(refused.Result().Cookies()))
111 }
112
113 // Sign in: the POST spends the link and mints the session.
114 signed := postLink(t, a, action, token)
115 if signed.Code != http.StatusSeeOther || signed.Header().Get("Location") != "/auth/landed" || !sessionCookie(signed) {
116 t.Fatalf("POST %s = %d %q, want 303 /auth/landed with a session", action, signed.Code, signed.Header().Get("Location"))
117 }
118 cookies := signed.Result().Cookies()
119 if list := get(t, a, "/", cookies...); list.Code != http.StatusOK {
120 t.Fatalf("GET / with the new session = %d, want 200", list.Code)
121 }
122
123 // Spent: the door still opens (it looks nothing up), but its button
124 // now says the link expired.
125 action, token = openLink(t, a, link)
126 again := postLink(t, a, action, token)
127 if sessionCookie(again) || again.Header().Get("Location") != "/signin?err=expired" {
128 t.Fatalf("second POST = %d %q, want /signin?err=expired and no session", again.Code, again.Header().Get("Location"))
129 }
130}
131
132// TestALinkRefusedAtRedemptionIsTheDoor: an address on the allowlist
133// when its link was mailed but off it when the link is redeemed — the
134// list changed and the instance restarted in between — gets the sign-in
135// door with the refusal on it, in rauth's 403 (Config.Refused, refused),
136// and no session.
137func TestALinkRefusedAtRedemptionIsTheDoor(t *testing.T) {
138 const leaver = "leaver@example.test"
139 cfg, mailer := newConfig(t)
140 cfg.AllowedAddresses = []string{allowedAddress, leaver}
141 d := openTestDB(t, cfg)
142 before, b, err := slides.New(d, cfg)
143 if err != nil {
144 t.Fatal(err)
145 }
146 t.Cleanup(func() { b.Close() })
147 mailer.flush = b.FlushMail
148 link := mailedLink(t, &testApp{Handler: localised(t, before), mailer: mailer, db: d}, leaver)
149
150 // The same database, restarted without the leaver on the list.
151 cfg.AllowedAddresses = []string{allowedAddress}
152 afterMux, after, err := slides.New(d, cfg)
153 if err != nil {
154 t.Fatal(err)
155 }
156 t.Cleanup(func() { after.Close() })
157 h := localised(t, afterMux)
158
159 rec := followLink(t, h, link)
160 if rec.Code != http.StatusForbidden || rec.Header().Get("Location") != "" {
161 t.Fatalf("refused redemption = %d %q, want 403 and the door itself", rec.Code, rec.Header().Get("Location"))
162 }
163 if sessionCookie(rec) {
164 t.Fatal("a refused sign-in set a session cookie")
165 }
166 body := rec.Body.String()
167 for _, want := range []string{"estilo-door", `name="address"`, `action="/signin"`, "can’t sign in to Slides"} {
168 if !strings.Contains(body, want) {
169 t.Errorf("refusal door lacks %q:\n%s", want, body)
170 }
171 }
172 if strings.Contains(body, "not admitted here") {
173 t.Error("the refusal is rastrillo's bare text")
174 }
175 if got := rec.Header().Get("Cache-Control"); got != "no-store" {
176 t.Errorf("refusal door Cache-Control = %q, want no-store", got)
177 }
178}
179