| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/url" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | calendar "amadan.net/oficina/calendar/internal/calendar" |
| 10 | ) |
| 11 | |
| 12 | // TestEmailedLinkOpensTheConfirmDoor walks the magic-link sign-in end to |
| 13 | // end: begin, the mail, GET of the emailed link (the confirm door, which |
| 14 | // spends nothing), the door's POST, and a session that opens a view. |
| 15 | // |
| 16 | // GET /auth/verify used to sign in on sight, so a mail-security gateway |
| 17 | // that fetches every link in an inbound message (Safe Links, Proofpoint) |
| 18 | // spent it before the person clicked, and they were told it had expired. |
| 19 | // rastrillo now confirms on GET and redeems on POST; this pins that the |
| 20 | // confirm step is Calendar's own door with one Sign in button, not |
| 21 | // rastrillo's plain English fallback, and that the POST is mounted. |
| 22 | func TestEmailedLinkOpensTheConfirmDoor(t *testing.T) { |
| 23 | app := newApp(t) |
| 24 | h := app.Handler() |
| 25 | |
| 26 | if w := post(t, h, "/signin", url.Values{"address": {"someone@example.com"}, "force": {"1"}}); w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/signin?sent=1" { |
| 27 | t.Fatalf("POST /signin = %d %q, want 303 /signin?sent=1", w.Code, w.Header().Get("Location")) |
| 28 | } |
| 29 | body, err := app.LastMailForTest() |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | link := magicLink(t, body) |
| 34 | u, err := url.Parse(link) |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | |
| 39 | // Opening it, twice, as a scanner and then the person would. |
| 40 | for _, who := range []string{"scanner", "person"} { |
| 41 | rec := get(t, h, link) |
| 42 | if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 { |
| 43 | t.Fatalf("%s's GET = %d with %d cookies, want 200 and nothing minted", who, rec.Code, len(rec.Result().Cookies())) |
| 44 | } |
| 45 | page := rec.Body.String() |
| 46 | for _, want := range []string{"estilo-door", "estilo-signin-form", "<span>Calendar</span>", "<title>Sign in · Calendar</title>", `name="token" value="` + u.Query().Get("token") + `"`} { |
| 47 | if !strings.Contains(page, want) { |
| 48 | t.Errorf("%s: confirm door lacks %q:\n%s", who, want, page) |
| 49 | } |
| 50 | } |
| 51 | if n := strings.Count(page, "<button"); n != 1 || !strings.Contains(page, `type="submit" autofocus>Sign in</button>`) { |
| 52 | t.Errorf("%s: confirm door has %d buttons, want exactly one Sign in:\n%s", who, n, page) |
| 53 | } |
| 54 | if strings.Contains(page, `name="address"`) { |
| 55 | t.Errorf("%s: confirm door asks for an address again", who) |
| 56 | } |
| 57 | if strings.Contains(page, "<title>Sign in to ") { |
| 58 | t.Errorf("%s: rastrillo's fallback confirm page, not the door", who) |
| 59 | } |
| 60 | // auth sets these before RenderConfirm; render must not undo them. |
| 61 | for header, want := range map[string]string{ |
| 62 | "Cache-Control": "no-store", |
| 63 | "Referrer-Policy": "strict-origin", |
| 64 | "X-Frame-Options": "DENY", |
| 65 | } { |
| 66 | if got := rec.Header().Get(header); got != want { |
| 67 | t.Errorf("%s: %s = %q, want %q", who, header, got, want) |
| 68 | } |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // A POST with no browser's same-origin evidence is refused and |
| 73 | // spends nothing. |
| 74 | action, token := openLink(t, h, link) |
| 75 | cross := calendar.RequestForTest(nil, http.MethodPost, action, url.Values{"token": {token}}) |
| 76 | cross.Header.Del("Origin") |
| 77 | cross.Header.Set("Sec-Fetch-Site", "cross-site") |
| 78 | if rec := serve(h, cross); rec.Code != http.StatusForbidden || len(rec.Result().Cookies()) != 0 { |
| 79 | t.Fatalf("cross-site POST = %d with %d cookies, want 403 and nothing minted", rec.Code, len(rec.Result().Cookies())) |
| 80 | } |
| 81 | |
| 82 | // Sign in: the POST spends the link and mints the session. |
| 83 | signed := postLink(t, h, action, token) |
| 84 | if signed.Code != http.StatusSeeOther || signed.Header().Get("Location") != "/" { |
| 85 | t.Fatalf("POST %s = %d %q, want 303 /", action, signed.Code, signed.Header().Get("Location")) |
| 86 | } |
| 87 | var session *http.Cookie |
| 88 | for _, c := range signed.Result().Cookies() { |
| 89 | if c.Value != "" && c.MaxAge >= 0 { |
| 90 | session = c |
| 91 | } |
| 92 | } |
| 93 | if session == nil { |
| 94 | t.Fatal("the confirm POST minted no session") |
| 95 | } |
| 96 | if view := follow(t, h, session, "/"); view.Code != http.StatusOK || !strings.Contains(view.Body.String(), `aria-label="Account: someone@example.com"`) { |
| 97 | t.Fatalf("GET / with the new session = %d, or it does not name the person", view.Code) |
| 98 | } |
| 99 | |
| 100 | // Spent: the door still opens (it looks nothing up), but its button |
| 101 | // now says the link expired. |
| 102 | action, token = openLink(t, h, link) |
| 103 | again := postLink(t, h, action, token) |
| 104 | if len(again.Result().Cookies()) != 0 || again.Header().Get("Location") != "/signin?err=expired" { |
| 105 | t.Fatalf("second POST = %d %q, want /signin?err=expired and no session", again.Code, again.Header().Get("Location")) |
| 106 | } |
| 107 | } |
| 108 | |