| 1 | package slidestest |
| 2 | |
| 3 | import ( |
| 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. |
| 14 | func 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. |
| 32 | func 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. |
| 52 | func 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. |
| 137 | func 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 | |