oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package calendartest
4
5import (
6 "context"
7 "io"
8 "log/slog"
9 "net"
10 "net/http"
11 "net/http/cookiejar"
12 "net/url"
13 "os/exec"
14 "path/filepath"
15 "runtime"
16 "strings"
17 "testing"
18 "time"
19
20 calendar "amadan.net/oficina/calendar/internal/calendar"
21)
22
23// TestQuickAddInARealBrowser drives the quick-add box in Chromium, with
24// script and then without it.
25//
26// Go starts the app and signs in — the magic-link flow needs the mail
27// recorder, which only this side can read — and hands node a live
28// origin and a session cookie. Playwright rather than chromedp: in this
29// environment chromedp starts a browser and parks at about:blank until
30// the Go test times out, so the existing browser walk cannot run at
31// all. A gate that cannot run is not a gate, and the house rule that
32// one test must cross from a computed value to painted output was
33// therefore unmet. This meets it.
34func TestQuickAddInARealBrowser(t *testing.T) {
35 // The listener comes first, because the origin is load-bearing:
36 // csrf.Protect measures against it and the sign-in link is built
37 // from it, so the app cannot be constructed until the port is known.
38 ln, err := net.Listen("tcp", "127.0.0.1:0")
39 if err != nil {
40 t.Fatalf("listen: %v", err)
41 }
42 origin := "http://" + ln.Addr().String()
43
44 app, err := calendar.New(calendar.Config{
45 DBPath: filepath.Join(t.TempDir(), "calendar.db"),
46 Env: "development",
47 Origin: origin,
48 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
49 })
50 if err != nil {
51 t.Fatalf("calendar.New: %v", err)
52 }
53 t.Cleanup(func() { app.Close() })
54
55 srv := &http.Server{Handler: app.Handler()}
56 go func() { _ = srv.Serve(ln) }()
57 t.Cleanup(func() { _ = srv.Close() })
58
59 session := signInOverHTTP(t, app, origin, "someone@example.com")
60
61 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
62 defer cancel()
63
64 cmd := exec.CommandContext(ctx, "node", "test/browser/quickadd.mjs",
65 origin, session.Name, session.Value)
66 cmd.Dir = repoRootForBrowser(t)
67
68 out, err := cmd.CombinedOutput()
69 t.Logf("node:\n%s", out)
70 if err != nil {
71 t.Fatalf("the browser checks failed: %v", err)
72 }
73}
74
75// repoRootForBrowser finds the repository from this file's own path, never
76// from the working directory: go test runs in the package directory and
77// the node script is addressed from the root.
78func repoRootForBrowser(t *testing.T) string {
79 t.Helper()
80 _, file, _, ok := runtime.Caller(0)
81 if !ok {
82 t.Fatal("runtime.Caller failed")
83 }
84 return filepath.Dir(filepath.Dir(filepath.Dir(file)))
85}
86
87// signInOverHTTP drives the real sign-in against the LIVE listener.
88//
89// App.SignInForTest cannot be used here: it presents calendar.TestOrigin
90// as its same-origin evidence, and this server is on a real loopback
91// port, so csrf.Protect refuses the POST and no link is ever mailed.
92// The failure reads as "no sign-in link was mailed", which names the
93// symptom and not the cause — hence this comment.
94func signInOverHTTP(t *testing.T, app *calendar.App, origin, address string) *http.Cookie {
95 t.Helper()
96 jar, err := cookiejar.New(nil)
97 if err != nil {
98 t.Fatalf("cookiejar: %v", err)
99 }
100 client := &http.Client{Jar: jar}
101
102 form := url.Values{"address": {address}, "force": {"1"}}
103 req, err := http.NewRequest(http.MethodPost, origin+"/signin",
104 strings.NewReader(form.Encode()))
105 if err != nil {
106 t.Fatalf("build signin request: %v", err)
107 }
108 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
109 req.Header.Set("Origin", origin)
110 res, err := client.Do(req)
111 if err != nil {
112 t.Fatalf("post signin: %v", err)
113 }
114 res.Body.Close()
115
116 body, err := app.LastMailForTest()
117 if err != nil || body == "" {
118 t.Fatalf("no sign-in link was mailed: %v", err)
119 }
120 link := magicLink(t, body)
121
122 // Open the link — the confirm door, which spends nothing — and
123 // press its Sign in button, which does.
124 res, err = client.Get(origin + link)
125 if err != nil {
126 t.Fatalf("open the link: %v", err)
127 }
128 door, err := io.ReadAll(res.Body)
129 res.Body.Close()
130 if err != nil {
131 t.Fatalf("read the confirm door: %v", err)
132 }
133 action, token, ok := calendar.ConfirmFormForTest(string(door))
134 if !ok {
135 t.Fatalf("opening the link drew no confirm door (status %d)", res.StatusCode)
136 }
137 req, err = http.NewRequest(http.MethodPost, origin+action,
138 strings.NewReader(url.Values{"token": {token}}.Encode()))
139 if err != nil {
140 t.Fatalf("build confirm request: %v", err)
141 }
142 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
143 req.Header.Set("Origin", origin)
144 req.Header.Set("Sec-Fetch-Site", "same-origin")
145 res, err = client.Do(req)
146 if err != nil {
147 t.Fatalf("confirm the link: %v", err)
148 }
149 res.Body.Close()
150
151 u, _ := url.Parse(origin)
152 cookies := jar.Cookies(u)
153 if len(cookies) == 0 {
154 t.Fatal("following the sign-in link minted no cookie")
155 }
156 return cookies[0]
157}
158