oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "html"
10 "io"
11 "log/slog"
12 "net/http"
13 "net/http/httptest"
14 "net/url"
15 "os"
16 "path/filepath"
17 "regexp"
18 "strings"
19 "sync"
20 "testing"
21 "time"
22
23 "amadan.net/oficina/calendar/internal/calendar/keymailtest"
24 "amadan.net/oficina/home/connect"
25 assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
26)
27
28// TestMain keeps Keymail classification inside the process: every DNS
29// query is answered NXDOMAIN and only the Keymail fixtures a test
30// registers answer HTTPS (keymailtest).
31func TestMain(m *testing.M) {
32 restore, err := keymailtest.Install()
33 if err != nil {
34 fmt.Fprintln(os.Stderr, "keymailtest.Install:", err)
35 os.Exit(1)
36 }
37 code := m.Run()
38 // No test may leave a sign-in mail worker running: every app a test
39 // builds is closed in its cleanup.
40 if code == 0 {
41 deadline := time.Now().Add(5 * time.Second)
42 for mailWorkers() > 0 && time.Now().Before(deadline) {
43 time.Sleep(10 * time.Millisecond)
44 }
45 if n := mailWorkers(); n > 0 {
46 fmt.Fprintf(os.Stderr, "FAIL: %d sign-in mail queue goroutines still running after the tests\n", n)
47 code = 1
48 }
49 }
50 restore()
51 os.Exit(code)
52}
53
54const (
55 keymailHost = "keymail.calendar.test"
56 keymailMember = "paul@" + keymailHost
57 keymailStranger = "stranger@" + keymailHost
58 plainMember = "paul@example.test"
59)
60
61// countingMailer remembers every address it was asked to write to.
62type countingMailer struct {
63 mu sync.Mutex
64 to []string
65 flush func() // waits for the sign-in mail queue (signin_mail.go)
66}
67
68func (m *countingMailer) Send(_ context.Context, to, _, _ string) error {
69 m.mu.Lock()
70 defer m.mu.Unlock()
71 m.to = append(m.to, to)
72 return nil
73}
74
75func (m *countingMailer) sent() []string {
76 if m.flush != nil {
77 m.flush()
78 }
79 m.mu.Lock()
80 defer m.mu.Unlock()
81 return append([]string(nil), m.to...)
82}
83
84type keymailFixture struct {
85 app *App
86 origin string
87 mail *countingMailer
88 km *keymailtest.Server
89}
90
91// newKeymailFixture builds an app admitting plainMember and
92// keymailMember, beside a Keymail deployment holding keymailMember and
93// keymailStranger. home configures Home, which moves the local form to
94// /signin/local.
95func newKeymailFixture(t *testing.T, home bool) *keymailFixture {
96 t.Helper()
97 km := keymailtest.Serve(t, keymailHost, keymailMember, keymailStranger)
98 mail := &countingMailer{}
99 cfg := Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: TestOrigin,
100 AllowedAddresses: plainMember + "," + keymailMember, Mailer: mail,
101 Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
102 if home {
103 key, err := assertioncrypto.Generate()
104 if err != nil {
105 t.Fatal(err)
106 }
107 manifest, _ := json.Marshal(map[string]string{"key": base64.StdEncoding.EncodeToString(key.SignPub())})
108 cfg.Origin = homeTestOrigin
109 cfg.Home = &connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "calendar-test", KeysBase64: base64.StdEncoding.EncodeToString(manifest), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}
110 }
111 a, err := New(cfg)
112 if err != nil {
113 t.Fatal(err)
114 }
115 t.Cleanup(func() { a.Close() })
116 mail.flush = a.signinMail.wait
117 return &keymailFixture{app: a, origin: cfg.Origin, mail: mail, km: km}
118}
119
120func (f *keymailFixture) post(form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
121 r := httptest.NewRequest(http.MethodPost, f.origin+"/signin", strings.NewReader(form.Encode()))
122 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
123 r.Header.Set("Origin", f.origin)
124 for _, c := range cookies {
125 r.AddCookie(c)
126 }
127 w := httptest.NewRecorder()
128 f.app.mux.ServeHTTP(w, r)
129 return w
130}
131
132func (f *keymailFixture) get(path string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
133 r := httptest.NewRequest(http.MethodGet, f.origin+path, nil)
134 for _, c := range cookies {
135 r.AddCookie(c)
136 }
137 w := httptest.NewRecorder()
138 f.app.mux.ServeHTTP(w, r)
139 return w
140}
141
142var authorizeHref = regexp.MustCompile(`href="(https://` + regexp.QuoteMeta(keymailHost) + `/oauth/authorize\?[^"]+)"`)
143
144// continuation asserts rec is the page between the form and Keymail —
145// a 200 document with a meta refresh, never a 303 out of the form POST
146// (Safari applies form-action to that redirect) — and returns the
147// authorize URL it carries.
148func continuation(t *testing.T, label string, rec *httptest.ResponseRecorder) *url.URL {
149 t.Helper()
150 if rec.Code != http.StatusOK || rec.Header().Get("Location") != "" {
151 t.Fatalf("%s: %d %q, want 200 and the continuation page to Keymail", label, rec.Code, rec.Header().Get("Location"))
152 }
153 body := rec.Body.String()
154 m := authorizeHref.FindStringSubmatch(body)
155 if m == nil || !strings.Contains(body, `http-equiv="refresh"`) {
156 t.Fatalf("%s: no Keymail continuation on the page:\n%s", label, body)
157 }
158 u, err := url.Parse(html.UnescapeString(m[1]))
159 if err != nil {
160 t.Fatal(err)
161 }
162 return u
163}
164
165// TestKeymailAddressGoesToKeymailWhetherPermittedOrNot: in both modes,
166// whether an address goes to Keymail depends on its domain alone. A
167// permitted and a non-permitted Keymail address get the same first
168// answer, the continuation page, and neither is mailed. Before, every
169// permitted address was forced to an emailed link and a non-permitted
170// one was answered ?sent=1.
171func TestKeymailAddressGoesToKeymailWhetherPermittedOrNot(t *testing.T) {
172 for _, home := range []bool{false, true} {
173 t.Run(fmt.Sprintf("home=%v", home), func(t *testing.T) {
174 f := newKeymailFixture(t, home)
175 member := f.post(url.Values{"address": {keymailMember}})
176 stranger := f.post(url.Values{"address": {keymailStranger}})
177 mu := continuation(t, "permitted Keymail address", member)
178 su := continuation(t, "non-permitted Keymail address", stranger)
179 mask := func(rec *httptest.ResponseRecorder, u *url.URL) string {
180 return strings.ReplaceAll(rec.Body.String(), html.EscapeString(u.String()), "AUTHORIZE")
181 }
182 if a, b := mask(member, mu), mask(stranger, su); a != b {
183 t.Errorf("the two Keymail answers differ beyond the authorize URL:\n%s\n----\n%s", a, b)
184 }
185 if got := f.mail.sent(); len(got) != 0 {
186 t.Errorf("a Keymail address was mailed: %v", got)
187 }
188 if n := f.km.Lookups(); n != 2 {
189 t.Errorf("Keymail saw %d lookups, want 2", n)
190 }
191 })
192 }
193}
194
195// TestNonPermittedPlainAddressIsAnsweredSentAndNotMailed: an address
196// with no Keymail gets ?sent=1, permitted or not; only the permitted
197// one is mailed.
198func TestNonPermittedPlainAddressIsAnsweredSentAndNotMailed(t *testing.T) {
199 f := newKeymailFixture(t, false)
200 for _, address := range []string{"someone@else.test", plainMember} {
201 rec := f.post(url.Values{"address": {address}})
202 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
203 t.Fatalf("POST /signin %q: %d %q, want 303 /signin?sent=1", address, rec.Code, rec.Header().Get("Location"))
204 }
205 }
206 if got := f.mail.sent(); len(got) != 1 || got[0] != plainMember {
207 t.Fatalf("mail = %v, want one, to %s", got, plainMember)
208 }
209}
210
211// TestKeymailFailureFallsBackToAnEmailLink: the page rastrillo lands a
212// failed Keymail exchange on (?force=1&err=keymail) carries force, so
213// the retry mails a link instead of returning to Keymail; the ordinary
214// form carries none.
215func TestKeymailFailureFallsBackToAnEmailLink(t *testing.T) {
216 f := newKeymailFixture(t, false)
217 forced := `name="force" value="1"`
218 if strings.Contains(f.get("/signin").Body.String(), forced) {
219 t.Fatal("the ordinary form forces an email link")
220 }
221 if !strings.Contains(f.get("/signin?force=1&err=keymail").Body.String(), forced) {
222 t.Fatal("the err=keymail form does not carry force; a failed Keymail sign-in would loop back to Keymail")
223 }
224 rec := f.post(url.Values{"address": {keymailMember}, "force": {"1"}})
225 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
226 t.Fatalf("forced POST: %d %q", rec.Code, rec.Header().Get("Location"))
227 }
228 if got := f.mail.sent(); len(got) != 1 || got[0] != keymailMember {
229 t.Fatalf("mail = %v, want one link to %s", got, keymailMember)
230 }
231 if f.km.Lookups() != 0 {
232 t.Error("a forced sign-in classified the address")
233 }
234}
235
236// TestKeymailRefusalIsTheDoor: a non-permitted address Keymail verified
237// is refused after verification with the door, rendered in the
238// refusal's own 403 with the form for a different address — not
239// rastrillo's bare text, and not a redirect to a query anybody could
240// type. An admitted one is signed in. Both modes.
241func TestKeymailRefusalIsTheDoor(t *testing.T) {
242 for _, home := range []bool{false, true} {
243 t.Run(fmt.Sprintf("home=%v", home), func(t *testing.T) {
244 f := newKeymailFixture(t, home)
245 form := "/signin"
246 if home {
247 form = "/signin/local"
248 }
249 roundTrip := func(address string) *httptest.ResponseRecorder {
250 begin := f.post(url.Values{"address": {address}})
251 u := continuation(t, address, begin)
252 return f.get("/auth/callback?"+url.Values{"state": {u.Query().Get("state")}, "code": {f.km.Approve(address)}}.Encode(), begin.Result().Cookies()...)
253 }
254 cb := roundTrip(keymailMember)
255 if cb.Code != http.StatusSeeOther || strings.Contains(cb.Header().Get("Location"), "err=") {
256 t.Fatalf("permitted callback: %d %q", cb.Code, cb.Header().Get("Location"))
257 }
258 // The continuation ends signed in: standalone, the session
259 // opens a view that names the person; with Home, the callback
260 // hands over to connect's local completion.
261 if home {
262 if loc := cb.Header().Get("Location"); loc != connect.LocalCompletePath {
263 t.Fatalf("permitted callback with Home lands on %q, want %s", loc, connect.LocalCompletePath)
264 }
265 } else if day := f.get("/agenda", cb.Result().Cookies()...); day.Code != http.StatusOK || !strings.Contains(day.Body.String(), `aria-label="Account: `+keymailMember+`"`) {
266 t.Fatalf("GET /agenda with the Keymail session = %d, or it does not name %s", day.Code, keymailMember)
267 }
268 cb = roundTrip(keymailStranger)
269 if cb.Code != http.StatusForbidden || cb.Header().Get("Location") != "" {
270 t.Fatalf("non-permitted 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())
271 }
272 door := cb.Body.String()
273 for _, want := range []string{"estilo-door", `name="address"`, "can’t sign in to Calendar"} {
274 if !strings.Contains(door, want) {
275 t.Errorf("refusal door lacks %q:\n%s", want, door)
276 }
277 }
278 // Nobody may summon the sentence by typing a URL (Astra, round 3).
279 if spoof := f.get(form + "?err=refused").Body.String(); strings.Contains(spoof, "can’t sign in to Calendar") {
280 t.Fatalf("anybody can show the refusal by visiting %s?err=refused", form)
281 }
282 })
283 }
284}
285