oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs_test
2
3import (
4 "html"
5 "net/http"
6 "net/http/httptest"
7 "net/url"
8 "regexp"
9 "strings"
10 "testing"
11
12 "github.com/carlosframework/docs/internal/docs"
13)
14
15// confirmDoor is what a test reads off the page an emailed link opens:
16// the form's target and the token it carries, taken from the page itself
17// rather than from the link, so the POST is the one the button sends.
18type confirmDoor struct {
19 action, token string
20}
21
22var (
23 formTag = regexp.MustCompile(`<form[^>]*>`)
24 formAction = regexp.MustCompile(`action="([^"]*)"`)
25 tokenField = regexp.MustCompile(`<input type="hidden" name="token" value="([^"]*)">`)
26 submitButton = regexp.MustCompile(`<button[^>]*type="submit"[^>]*>([^<]*)</button>`)
27)
28
29// readConfirmDoor checks that rec is the confirm step drawn as Docs'
30// own sign-in door, and returns what its one button submits.
31func readConfirmDoor(t *testing.T, rec *httptest.ResponseRecorder) confirmDoor {
32 t.Helper()
33 body := rec.Body.String()
34 if rec.Code != http.StatusOK {
35 t.Fatalf("GET verify: status %d, want 200 and the confirm door:\n%s", rec.Code, body)
36 }
37 for _, c := range rec.Result().Cookies() {
38 if c.MaxAge >= 0 && c.Value != "" {
39 t.Fatalf("GET verify set cookie %q; the GET must spend nothing and sign nobody in", c.Name)
40 }
41 }
42 // The app's chrome: the door (estilo), Docs' own mark and name, the
43 // shell's bar. Not rastrillo's plain English fallback page.
44 // (Tests run without the catalog loaded, so the app's own name in the
45 // tab title is its key; the door's words are estilo's English.)
46 for _, want := range []string{"estilo-door", "estilo-signin", "<span>Docs</span>", "estilo-bar", "<title>Sign in · ", "<h1>Sign in</h1>"} {
47 if !strings.Contains(body, want) {
48 t.Errorf("confirm page lacks %q: not Docs' sign-in door", want)
49 }
50 }
51 if strings.Contains(body, "Sign in to localhost") || strings.Contains(body, "Sign in to example.com") {
52 t.Error("confirm page is rastrillo's fallback, not Docs' door")
53 }
54 // One form, posting; one button, "Sign in"; no address field.
55 forms := formTag.FindAllString(body, -1)
56 var confirm []string
57 for _, f := range forms {
58 if strings.Contains(f, `action="/auth/verify"`) {
59 confirm = append(confirm, f)
60 }
61 }
62 if len(confirm) != 1 || !strings.Contains(confirm[0], `method="post"`) {
63 t.Fatalf("want exactly one POST form to /auth/verify, got %q", confirm)
64 }
65 if strings.Contains(body, `name="address"`) {
66 t.Error("confirm door shows the address form; it should show one Sign in button")
67 }
68 start := strings.Index(body, confirm[0])
69 end := strings.Index(body[start:], "</form>")
70 form := body[start : start+end]
71 buttons := submitButton.FindAllStringSubmatch(form, -1)
72 if len(buttons) != 1 || strings.TrimSpace(buttons[0][1]) != "Sign in" {
73 t.Fatalf("want one Sign in button in the confirm form, got %q", buttons)
74 }
75 token := tokenField.FindStringSubmatch(form)
76 if token == nil || token[1] == "" {
77 t.Fatal("confirm form carries no token")
78 }
79 // The page's address is a live credential: rastrillo's headers must
80 // survive Docs' rendering.
81 h := rec.Header()
82 if h.Get("Cache-Control") != "no-store" || h.Get("Referrer-Policy") != "strict-origin" || h.Get("X-Frame-Options") != "DENY" {
83 t.Errorf("confirm headers loosened: Cache-Control=%q Referrer-Policy=%q X-Frame-Options=%q",
84 h.Get("Cache-Control"), h.Get("Referrer-Policy"), h.Get("X-Frame-Options"))
85 }
86 if !strings.Contains(strings.Join(h.Values("Content-Security-Policy"), ";"), "frame-ancestors 'none'") {
87 t.Error("confirm page can be framed")
88 }
89 action := formAction.FindStringSubmatch(confirm[0])
90 return confirmDoor{action: html.UnescapeString(action[1]), token: html.UnescapeString(token[1])}
91}
92
93// press submits the confirm door's button the way a browser does from
94// the page: a same-origin form POST.
95func (d confirmDoor) press(t *testing.T, app *docs.App, site string) *httptest.ResponseRecorder {
96 t.Helper()
97 req := httptest.NewRequest(http.MethodPost, d.action, strings.NewReader(url.Values{"token": {d.token}}.Encode()))
98 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
99 req.Header.Set("Sec-Fetch-Site", site)
100 rec := httptest.NewRecorder()
101 app.Handler().ServeHTTP(rec, req)
102 return rec
103}
104
105func sessionCookies(rec *httptest.ResponseRecorder) []*http.Cookie {
106 var live []*http.Cookie
107 for _, c := range rec.Result().Cookies() {
108 if c.MaxAge >= 0 && c.Value != "" {
109 live = append(live, c)
110 }
111 }
112 return live
113}
114
115// The emailed-link sign-in end to end: begin (POST /signin), the mail,
116// GET of the link drawing the confirm door and spending nothing, the
117// button's POST spending it, and a session that reaches the list.
118func TestEmailSignInConfirmsInTheDoorThenSpendsOnPost(t *testing.T) {
119 app := members(t, "alice@example.com")
120
121 // Begin and mail: TestSignIn POSTs /signin and reads the link out of
122 // the mail the queue delivered.
123 link, err := app.TestSignIn(t, "alice@example.com")
124 if err != nil {
125 t.Fatal(err)
126 }
127
128 // A link scanner fetches the link first. Twice, even: it spends
129 // nothing, so the person's own click still works.
130 readConfirmDoor(t, get(t, app, nil, link))
131 door := readConfirmDoor(t, get(t, app, nil, link))
132
133 // A post carrying cross-site evidence is refused, and spends nothing.
134 if rec := door.press(t, app, "cross-site"); rec.Code != http.StatusForbidden || len(sessionCookies(rec)) != 0 {
135 t.Fatalf("cross-site confirm: %d with cookies %v, want a bare 403", rec.Code, sessionCookies(rec))
136 }
137
138 signedIn := door.press(t, app, "same-origin")
139 if signedIn.Code != http.StatusSeeOther || signedIn.Header().Get("Location") != "/auth/local/complete" {
140 t.Fatalf("POST verify: %d → %q, want 303 → /auth/local/complete", signedIn.Code, signedIn.Header().Get("Location"))
141 }
142 cookies := sessionCookies(signedIn)
143 if len(cookies) == 0 {
144 t.Fatal("POST verify set no session cookie")
145 }
146 landed := get(t, app, cookies, "/auth/local/complete")
147 if landed.Code != http.StatusSeeOther || landed.Header().Get("Location") != "/" {
148 t.Fatalf("landing: %d → %q, want 303 → /", landed.Code, landed.Header().Get("Location"))
149 }
150 if rec := get(t, app, cookies, "/"); rec.Code != http.StatusOK {
151 t.Fatalf("GET / with the new session: %d, want 200", rec.Code)
152 }
153
154 // Single use: the same button again finds the link spent.
155 again := door.press(t, app, "same-origin")
156 if again.Code != http.StatusSeeOther || again.Header().Get("Location") != "/signin?err=expired" || len(sessionCookies(again)) != 0 {
157 t.Fatalf("second POST: %d → %q, want 303 → /signin?err=expired and no session", again.Code, again.Header().Get("Location"))
158 }
159}
160
161// A link mailed while the address was admitted, pressed after it no
162// longer is: the door, with the refusal, inside the 403 (Config.Refused),
163// and no session.
164func TestEmailSignInRefusedAfterConfirmGetsTheDoor(t *testing.T) {
165 app := members(t, "alice@example.com")
166 alice := signInCookies(t, app, "alice@example.com")
167 sqid := createDoc(t, app, alice, "Minutes")
168 post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader")
169 link, err := app.TestSignIn(t, "bob@example.com")
170 if err != nil {
171 t.Fatal(err)
172 }
173 post(t, app, alice, "/d/"+sqid+"/share/revoke", "address=bob@example.com")
174
175 door := readConfirmDoor(t, get(t, app, nil, link))
176 refused := door.press(t, app, "same-origin")
177 body := refused.Body.String()
178 if refused.Code != http.StatusForbidden {
179 t.Fatalf("refused POST verify: %d, want 403:\n%s", refused.Code, body)
180 }
181 if strings.Contains(body, "verified but not admitted") {
182 t.Fatal("refusal is rastrillo's bare text, not the door")
183 }
184 for _, want := range []string{"estilo-door", `name="address"`, `rst-tone="negative"`} {
185 if !strings.Contains(body, want) {
186 t.Errorf("refusal lacks %q: want the sign-in door with the refusal", want)
187 }
188 }
189 // The catalog key in tests, the sentence with the catalog loaded.
190 if !strings.Contains(body, "docs.signin.error.refused") && !strings.Contains(body, "can’t sign in to Docs") {
191 t.Error("refusal door does not say the address was refused")
192 }
193 if refused.Header().Get("Cache-Control") != "no-store" {
194 t.Error("refusal is cacheable")
195 }
196 if len(sessionCookies(refused)) != 0 {
197 t.Fatal("a refused address got a session cookie")
198 }
199 // And the sentence is not something a query can conjure.
200 if spoof := get(t, app, nil, "/signin?err=refused").Body.String(); strings.Contains(spoof, "docs.signin.error.refused") || strings.Contains(spoof, "can’t sign in to Docs") {
201 t.Fatal("anybody can show the refusal by visiting /signin?err=refused")
202 }
203}
204