| 1 | package docs_test |
| 2 | |
| 3 | import ( |
| 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. |
| 18 | type confirmDoor struct { |
| 19 | action, token string |
| 20 | } |
| 21 | |
| 22 | var ( |
| 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. |
| 31 | func 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. |
| 95 | func (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 | |
| 105 | func 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. |
| 118 | func 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. |
| 164 | func 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 | |