| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "html" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "net/url" |
| 9 | "os" |
| 10 | "regexp" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "amadan.net/oficina/sheets/internal/sheets/keymailtest" |
| 16 | ) |
| 17 | |
| 18 | // TestMain keeps Keymail classification inside the process: every DNS |
| 19 | // query is answered NXDOMAIN and only the Keymail fixtures a test |
| 20 | // registers answer HTTPS (keymailtest). Before it, every POST /signin |
| 21 | // in this suite classified its address against the real network. |
| 22 | func TestMain(m *testing.M) { |
| 23 | restore, err := keymailtest.Install() |
| 24 | if err != nil { |
| 25 | fmt.Fprintln(os.Stderr, "keymailtest.Install:", err) |
| 26 | os.Exit(1) |
| 27 | } |
| 28 | code := m.Run() |
| 29 | // No test may leave a sign-in mail worker running: every plugin a |
| 30 | // test builds is closed in its cleanup (CloseSigninMail). |
| 31 | if code == 0 { |
| 32 | deadline := time.Now().Add(5 * time.Second) |
| 33 | for mailWorkers() > 0 && time.Now().Before(deadline) { |
| 34 | time.Sleep(10 * time.Millisecond) |
| 35 | } |
| 36 | if n := mailWorkers(); n > 0 { |
| 37 | fmt.Fprintf(os.Stderr, "FAIL: %d sign-in mail queue goroutines still running after the tests\n", n) |
| 38 | code = 1 |
| 39 | } |
| 40 | } |
| 41 | restore() |
| 42 | os.Exit(code) |
| 43 | } |
| 44 | |
| 45 | const ( |
| 46 | keymailHost = "keymail.sheets.test" |
| 47 | keymailMember = "paul@" + keymailHost |
| 48 | keymailStranger = "stranger@" + keymailHost |
| 49 | ) |
| 50 | |
| 51 | // keymailApp is a standalone app whose owners include keymailMember, |
| 52 | // beside a Keymail deployment holding keymailMember and keymailStranger. |
| 53 | func keymailApp(t *testing.T) (*App, *recorder, *keymailtest.Server) { |
| 54 | t.Helper() |
| 55 | km := keymailtest.Serve(t, keymailHost, keymailMember, keymailStranger) |
| 56 | app, rec := appWithOwners(t, testOrigin, signedInAddress, keymailMember) |
| 57 | return app, rec, km |
| 58 | } |
| 59 | |
| 60 | func keymailPost(t *testing.T, h http.Handler, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { |
| 61 | t.Helper() |
| 62 | r := httptest.NewRequest(http.MethodPost, testOrigin+"/signin", strings.NewReader(form.Encode())) |
| 63 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 64 | r.Header.Set("Origin", testOrigin) |
| 65 | r.Header.Set("Sec-Fetch-Site", "same-origin") |
| 66 | for _, c := range cookies { |
| 67 | r.AddCookie(c) |
| 68 | } |
| 69 | w := httptest.NewRecorder() |
| 70 | h.ServeHTTP(w, r) |
| 71 | return w |
| 72 | } |
| 73 | |
| 74 | func keymailGet(t *testing.T, h http.Handler, path string, cookies ...*http.Cookie) *httptest.ResponseRecorder { |
| 75 | t.Helper() |
| 76 | r := httptest.NewRequest(http.MethodGet, testOrigin+path, nil) |
| 77 | for _, c := range cookies { |
| 78 | r.AddCookie(c) |
| 79 | } |
| 80 | w := httptest.NewRecorder() |
| 81 | h.ServeHTTP(w, r) |
| 82 | return w |
| 83 | } |
| 84 | |
| 85 | var authorizeHref = regexp.MustCompile(`href="(https://` + regexp.QuoteMeta(keymailHost) + `/oauth/authorize\?[^"]+)"`) |
| 86 | |
| 87 | // continuation asserts rec is the page between the form and Keymail — |
| 88 | // a 200 document with a meta refresh, never a redirect from the form |
| 89 | // POST (Safari applies form-action to that redirect) — and returns the |
| 90 | // authorize URL it carries. |
| 91 | func continuation(t *testing.T, label string, rec *httptest.ResponseRecorder) *url.URL { |
| 92 | t.Helper() |
| 93 | if rec.Code != http.StatusOK || rec.Header().Get("Location") != "" { |
| 94 | t.Fatalf("%s: %d %q, want 200 and the continuation page — the hop to Keymail must not be a redirect from the form POST", label, rec.Code, rec.Header().Get("Location")) |
| 95 | } |
| 96 | body := rec.Body.String() |
| 97 | m := authorizeHref.FindStringSubmatch(body) |
| 98 | if m == nil || !strings.Contains(body, `http-equiv="refresh"`) { |
| 99 | t.Fatalf("%s: no Keymail continuation on the page:\n%s", label, body) |
| 100 | } |
| 101 | u, err := url.Parse(html.UnescapeString(m[1])) |
| 102 | if err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | return u |
| 106 | } |
| 107 | |
| 108 | // TestKeymailAddressGoesToKeymailWhetherAdmittedOrNot: whether an |
| 109 | // address goes to Keymail depends on its domain alone. An admitted and |
| 110 | // a non-admitted Keymail address get the same first answer — the |
| 111 | // continuation page — so the answer says nothing about who is on the |
| 112 | // team. Before, the admission gate answered the stranger ?sent=1 and |
| 113 | // sent the member to Keymail with a 303 out of the form POST, which |
| 114 | // Safari refused under form-action 'self'. |
| 115 | func TestKeymailAddressGoesToKeymailWhetherAdmittedOrNot(t *testing.T) { |
| 116 | app, mail, km := keymailApp(t) |
| 117 | h := app.Router() |
| 118 | member := keymailPost(t, h, url.Values{"address": {keymailMember}}) |
| 119 | stranger := keymailPost(t, h, url.Values{"address": {keymailStranger}}) |
| 120 | mu := continuation(t, "admitted Keymail address", member) |
| 121 | su := continuation(t, "non-admitted Keymail address", stranger) |
| 122 | mask := func(rec *httptest.ResponseRecorder, u *url.URL) string { |
| 123 | return strings.ReplaceAll(rec.Body.String(), html.EscapeString(u.String()), "AUTHORIZE") |
| 124 | } |
| 125 | if a, b := mask(member, mu), mask(stranger, su); a != b { |
| 126 | t.Errorf("the two Keymail answers differ beyond the authorize URL:\nadmitted:\n%s\nnot admitted:\n%s", a, b) |
| 127 | } |
| 128 | if got := mail.addresses(); len(got) != 0 { |
| 129 | t.Errorf("a Keymail address was mailed: %v", got) |
| 130 | } |
| 131 | if n := km.Lookups(); n != 2 { |
| 132 | t.Errorf("Keymail saw %d lookups, want 2: both addresses classified", n) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // TestHomeLocalKeymailIsTheSameAnswer is the same in Home mode, where the |
| 137 | // local form posts to /signin under LocalGuard. |
| 138 | func TestHomeLocalKeymailIsTheSameAnswer(t *testing.T) { |
| 139 | km := keymailtest.Serve(t, keymailHost, keymailMember, keymailStranger) |
| 140 | f := newHomeFixture(t) |
| 141 | member := f.request(t, http.MethodPost, "/signin", url.Values{"address": {signedInAddress}}, false) |
| 142 | if member.Code != http.StatusSeeOther || member.Header().Get("Location") != "/signin?sent=1" { |
| 143 | t.Fatalf("Home-local plain member: %d %q", member.Code, member.Header().Get("Location")) |
| 144 | } |
| 145 | stranger := f.request(t, http.MethodPost, "/signin", url.Values{"address": {keymailStranger}}, false) |
| 146 | continuation(t, "Home-local non-admitted Keymail address", stranger) |
| 147 | if n := km.Lookups(); n != 1 { |
| 148 | t.Errorf("Keymail saw %d lookups, want 1", n) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | // TestNonAdmittedPlainAddressIsAnsweredSentAndNotMailed: an address |
| 153 | // with no Keymail still gets ?sent=1, admitted or not, and only the |
| 154 | // admitted one is mailed. |
| 155 | func TestNonAdmittedPlainAddressIsAnsweredSentAndNotMailed(t *testing.T) { |
| 156 | app, mail, _ := keymailApp(t) |
| 157 | h := app.Router() |
| 158 | for _, address := range []string{"stranger@example.com", signedInAddress} { |
| 159 | rec := keymailPost(t, h, url.Values{"address": {address}}) |
| 160 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { |
| 161 | t.Fatalf("POST /signin %q: %d %q, want 303 /signin?sent=1", address, rec.Code, rec.Header().Get("Location")) |
| 162 | } |
| 163 | } |
| 164 | if got := mail.addresses(); len(got) != 1 || got[0] != signedInAddress { |
| 165 | t.Fatalf("mail = %v, want one, to %s", got, signedInAddress) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | // TestKeymailFailureFallsBackToAnEmailLink: the form rastrillo lands a |
| 170 | // failed Keymail exchange on carries force, and a forced POST mails a |
| 171 | // link without classifying. |
| 172 | func TestKeymailFailureFallsBackToAnEmailLink(t *testing.T) { |
| 173 | app, mail, km := keymailApp(t) |
| 174 | h := app.Router() |
| 175 | if body := keymailGet(t, h, "/signin?force=1&err=keymail").Body.String(); !strings.Contains(body, `name="force"`) { |
| 176 | t.Fatal("the err=keymail page does not carry force") |
| 177 | } |
| 178 | rec := keymailPost(t, h, url.Values{"address": {keymailMember}, "force": {"1"}}) |
| 179 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { |
| 180 | t.Fatalf("forced POST: %d %q", rec.Code, rec.Header().Get("Location")) |
| 181 | } |
| 182 | if got := mail.addresses(); len(got) != 1 || got[0] != keymailMember { |
| 183 | t.Fatalf("mail = %v, want one link to %s", got, keymailMember) |
| 184 | } |
| 185 | if km.Lookups() != 0 { |
| 186 | t.Error("a forced sign-in classified the address") |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | // TestKeymailRefusalIsTheDoor: a non-admitted address Keymail verified |
| 191 | // is refused after verification with the sign-in door, rendered in the |
| 192 | // refusal's own 403 by auth's Refused hook (App.refused) — not |
| 193 | // rastrillo's bare text, and not a redirect to a query anybody could |
| 194 | // type. No session is minted. An admitted one is signed in, through the |
| 195 | // continuation page, and its session opens the list. |
| 196 | func TestKeymailRefusalIsTheDoor(t *testing.T) { |
| 197 | app, _, km := keymailApp(t) |
| 198 | h := app.Router() |
| 199 | roundTrip := func(address string) *httptest.ResponseRecorder { |
| 200 | begin := keymailPost(t, h, url.Values{"address": {address}}) |
| 201 | u := continuation(t, address, begin) |
| 202 | return keymailGet(t, h, "/auth/callback?"+url.Values{"state": {u.Query().Get("state")}, "code": {km.Approve(address)}}.Encode(), begin.Result().Cookies()...) |
| 203 | } |
| 204 | cb := roundTrip(keymailMember) |
| 205 | if cb.Code != http.StatusSeeOther || cb.Header().Get("Location") != "/sheets" { |
| 206 | t.Fatalf("admitted callback: %d %q, want 303 /sheets", cb.Code, cb.Header().Get("Location")) |
| 207 | } |
| 208 | if list := page(t, app, cb.Result().Cookies(), "/sheets"); list.Code != http.StatusOK || !strings.Contains(list.Body.String(), keymailMember) { |
| 209 | t.Fatalf("GET /sheets with the Keymail session = %d, or it does not name %s", list.Code, keymailMember) |
| 210 | } |
| 211 | cb = roundTrip(keymailStranger) |
| 212 | if cb.Code != http.StatusForbidden || cb.Header().Get("Location") != "" { |
| 213 | t.Fatalf("non-admitted callback: %d %q, want 403 and the door itself (bare text is the bug)\n%s", cb.Code, cb.Header().Get("Location"), cb.Body.String()) |
| 214 | } |
| 215 | for _, c := range cb.Result().Cookies() { |
| 216 | if strings.Contains(c.Name, "session") && c.MaxAge >= 0 && c.Value != "" { |
| 217 | t.Fatalf("a refused sign-in set a session cookie %q", c.Name) |
| 218 | } |
| 219 | } |
| 220 | door := cb.Body.String() |
| 221 | for _, want := range []string{"estilo-door", `name="address"`, "can’t sign in to Sheets"} { |
| 222 | if !strings.Contains(door, want) { |
| 223 | t.Errorf("refusal door lacks %q:\n%s", want, door) |
| 224 | } |
| 225 | } |
| 226 | // The sentence means "you proved this address and it is not |
| 227 | // admitted"; nobody may summon it by typing a URL (Astra, round 3). |
| 228 | if spoof := keymailGet(t, h, "/signin?err=refused").Body.String(); strings.Contains(spoof, "can’t sign in to Sheets") { |
| 229 | t.Fatal("anybody can show the refusal by visiting /signin?err=refused") |
| 230 | } |
| 231 | } |
| 232 | |