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 "crypto/rand"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/json"
8 "io"
9 "log/slog"
10 "net/http"
11 "net/http/httptest"
12 "path/filepath"
13 "strings"
14 "testing"
15 "time"
16
17 "amadan.net/rastrillo/rastrillo/sessions"
18)
19
20// The native sign-in crosses two rastrillo module paths. The app's
21// sessions are amadan.net/rastrillo/rastrillo's; rastrillo/native's
22// server only takes github.com/carlosframework/rastrillo's, which
23// native.go builds over the same database (the one import
24// TestNoPreMoveRastrillo allows). These tests drive the whole exchange
25// through the router, so a change to either package's sessions table,
26// cookie name, token hash or time layout fails here rather than in the
27// Mac or iPhone app: approval of a browser session, the exchange minting
28// a new one, that one being an ordinary session to the app, and expiry
29// and revocation holding on both sides.
30//
31// Three shapes, because the app reads a session three ways: a local
32// sign-in with no Home (auth's RequireSession), a local sign-in with
33// Home configured, and a Home sign-in (both through the Home
34// connection's own sessions, the second resolving an opaque subject).
35
36// nativeRig is one signed-in browser against one app.
37type nativeRig struct {
38 app *App
39 origin string
40 browser *http.Cookie
41}
42
43func nativeRigs(t *testing.T) map[string]func(*testing.T) *nativeRig {
44 return map[string]func(*testing.T) *nativeRig{
45 "local, no Home": func(t *testing.T) *nativeRig {
46 a, err := New(Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: TestOrigin, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))})
47 if err != nil {
48 t.Fatal(err)
49 }
50 t.Cleanup(func() { a.Close() })
51 return &nativeRig{a, TestOrigin, a.SignInForTest(t, "alice@example.test")}
52 },
53 "local, Home configured": func(t *testing.T) *nativeRig {
54 f := newHomeRouterFixture(t)
55 f.local(t)
56 return &nativeRig{f.app, homeTestOrigin, f.cookies[f.app.home.sessions.CookieName()]}
57 },
58 "Home sign-in": func(t *testing.T) *nativeRig {
59 f := newHomeRouterFixture(t)
60 f.local(t)
61 // The first signed-in page sets the person up; linking needs one.
62 if w := f.do("GET", "/day/2026-09-10", ""); w.Code != 200 {
63 t.Fatalf("first page = %d", w.Code)
64 }
65 if w := f.do("GET", "/account/home", ""); w.Code != 200 {
66 t.Fatalf("link page = %d %s", w.Code, w.Body.String())
67 }
68 start := homeRedirect(t, f.do("POST", "/account/home", ""))
69 homeRedirect(t, f.exchange(t, start, "opaque-home-alice"))
70 homeRedirect(t, f.do("POST", "/account/home/confirm", ""))
71 f.do("POST", "/signout", "")
72 start = homeRedirect(t, f.do("GET", "/signin", ""))
73 if got := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")); got != "/" {
74 t.Fatalf("Home sign-in landed on %q", got)
75 }
76 c := f.cookies[f.app.home.sessions.CookieName()]
77 var method string
78 if err := f.app.db.G.Raw(`SELECT method FROM sessions WHERE token_hash = ?`, sessions.HashToken(c.Value)).Scan(&method).Error; err != nil || method == "magiclink" || method == "" {
79 t.Fatalf("the fixture's session is not a Home session: method %q, %v", method, err)
80 }
81 return &nativeRig{f.app, homeTestOrigin, c}
82 },
83 }
84}
85
86func (n *nativeRig) do(t *testing.T, method, path, body string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
87 t.Helper()
88 r := httptest.NewRequest(method, n.origin+path, strings.NewReader(body))
89 r.Header.Set("Content-Type", "application/json")
90 r.Header.Set("Origin", n.origin)
91 for _, c := range cookies {
92 r.AddCookie(c)
93 }
94 w := httptest.NewRecorder()
95 n.app.mux.ServeHTTP(w, r)
96 return w
97}
98
99// request starts a native sign-in and returns its id and verifier.
100func (n *nativeRig) request(t *testing.T) (id, verifier string) {
101 t.Helper()
102 b := make([]byte, 32)
103 if _, err := rand.Read(b); err != nil {
104 t.Fatal(err)
105 }
106 verifier = base64.RawURLEncoding.EncodeToString(b)
107 sum := sha256.Sum256([]byte(verifier))
108 w := n.do(t, "POST", "/native/request", `{"challenge":"`+base64.RawURLEncoding.EncodeToString(sum[:])+`"}`)
109 var out struct{ ID string }
110 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &out) != nil || out.ID == "" {
111 t.Fatalf("POST /native/request = %d %q", w.Code, w.Body.String())
112 }
113 return out.ID, verifier
114}
115
116// approve is the signed-in browser pressing "Sign in to Calendar app".
117func (n *nativeRig) approve(t *testing.T, id string) *httptest.ResponseRecorder {
118 t.Helper()
119 return n.do(t, "POST", "/native/authorize?id="+id, "", n.browser)
120}
121
122func (n *nativeRig) exchange(t *testing.T, id, verifier string) *httptest.ResponseRecorder {
123 t.Helper()
124 return n.do(t, "POST", "/native/exchange", `{"id":"`+id+`","verifier":"`+verifier+`"}`)
125}
126
127// mint runs a whole approved exchange and returns the minted cookie.
128func (n *nativeRig) mint(t *testing.T) *http.Cookie {
129 t.Helper()
130 id, verifier := n.request(t)
131 if w := n.approve(t, id); w.Code != 200 {
132 t.Fatalf("approval = %d", w.Code)
133 }
134 w := n.exchange(t, id, verifier)
135 var minted struct{ Name, Value string }
136 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &minted) != nil {
137 t.Fatalf("exchange = %d %q", w.Code, w.Body.String())
138 }
139 return &http.Cookie{Name: minted.Name, Value: minted.Value}
140}
141
142// defaultCalendar is GET /api/native/session with one cookie: the
143// status, and whose default calendar it answered with.
144func (n *nativeRig) defaultCalendar(t *testing.T, c *http.Cookie) (int, int64) {
145 t.Helper()
146 w := n.do(t, "GET", "/api/native/session", "", c)
147 var s struct {
148 Default int64 `json:"default_calendar_id"`
149 }
150 _ = json.Unmarshal(w.Body.Bytes(), &s)
151 return w.Code, s.Default
152}
153
154func (n *nativeRig) setExpiry(t *testing.T, token string, at time.Time) {
155 t.Helper()
156 res := n.app.db.G.Exec(`UPDATE sessions SET expires_at = ? WHERE token_hash = ?`, at.UTC().Format(time.RFC3339), sessions.HashToken(token))
157 if res.Error != nil || res.RowsAffected != 1 {
158 t.Fatalf("expire session: %v, %d rows", res.Error, res.RowsAffected)
159 }
160}
161
162func TestNativeExchangeMintsASessionTheAppAccepts(t *testing.T) {
163 for name, build := range nativeRigs(t) {
164 t.Run(name, func(t *testing.T) {
165 n := build(t)
166 code, want := n.defaultCalendar(t, n.browser)
167 if code != 200 || want == 0 {
168 t.Fatalf("the browser's own session: GET /api/native/session = %d", code)
169 }
170 id, verifier := n.request(t)
171 if w := n.exchange(t, id, verifier); w.Code != http.StatusAccepted {
172 t.Fatalf("exchange before approval = %d, want 202", w.Code)
173 }
174 if w := n.approve(t, id); w.Code != 200 || !strings.Contains(w.Body.String(), "is signed in") {
175 t.Fatalf("approval = %d: %s", w.Code, w.Body.String())
176 }
177 wrong := make([]byte, 32)
178 _, _ = rand.Read(wrong)
179 if w := n.exchange(t, id, base64.RawURLEncoding.EncodeToString(wrong)); w.Code != http.StatusNotFound {
180 t.Fatalf("exchange with the wrong verifier = %d, want 404", w.Code)
181 }
182 w := n.exchange(t, id, verifier)
183 var minted struct{ Name, Value string }
184 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &minted) != nil {
185 t.Fatalf("exchange = %d %q", w.Code, w.Body.String())
186 }
187 // The pre-move package names the cookie the app reads, and
188 // mints a new token rather than handing back the browser's.
189 if minted.Name != n.browser.Name {
190 t.Fatalf("exchange minted cookie %q; the app reads %q", minted.Name, n.browser.Name)
191 }
192 if minted.Value == "" || minted.Value == n.browser.Value {
193 t.Fatal("exchange did not mint a session of its own")
194 }
195 // And the app takes it as the same person.
196 code, got := n.defaultCalendar(t, &http.Cookie{Name: minted.Name, Value: minted.Value})
197 if code != 200 {
198 t.Fatalf("the app refuses the session native minted: GET /api/native/session = %d", code)
199 }
200 if got != want {
201 t.Fatalf("the minted session is somebody else: default calendar %d, the browser's is %d", got, want)
202 }
203 // Spent: the same id never exchanges twice.
204 if w := n.exchange(t, id, verifier); w.Code != http.StatusNotFound {
205 t.Fatalf("second exchange = %d, want 404", w.Code)
206 }
207 })
208 }
209}
210
211func TestNativeSessionExpiryHolds(t *testing.T) {
212 for name, build := range nativeRigs(t) {
213 t.Run(name, func(t *testing.T) {
214 n := build(t)
215
216 // An expired browser session signs nothing in. (The approval
217 // page records whatever cookie it is shown; the exchange is
218 // where the session is authenticated, from scratch.)
219 n.setExpiry(t, n.browser.Value, time.Now().Add(-time.Minute))
220 id, verifier := n.request(t)
221 n.approve(t, id)
222 if w := n.exchange(t, id, verifier); w.Code == 200 {
223 t.Fatal("an expired browser session signed a native app in")
224 }
225
226 // Approved while live, expired before the exchange: refused.
227 n.setExpiry(t, n.browser.Value, time.Now().Add(time.Hour))
228 id, verifier = n.request(t)
229 n.approve(t, id)
230 n.setExpiry(t, n.browser.Value, time.Now().Add(-time.Minute))
231 if w := n.exchange(t, id, verifier); w.Code != http.StatusUnauthorized {
232 t.Fatalf("exchange of an expired approval = %d, want 401", w.Code)
233 }
234
235 // A minted session expires like any other.
236 n.setExpiry(t, n.browser.Value, time.Now().Add(time.Hour))
237 native := n.mint(t)
238 if code, _ := n.defaultCalendar(t, native); code != 200 {
239 t.Fatalf("live minted session: GET /api/native/session = %d", code)
240 }
241 n.setExpiry(t, native.Value, time.Now().Add(-time.Minute))
242 if code, _ := n.defaultCalendar(t, native); code == 200 {
243 t.Fatal("the app still accepts a minted session past its expiry")
244 }
245 })
246 }
247}
248
249func TestNativeSessionRevocationHolds(t *testing.T) {
250 for name, build := range nativeRigs(t) {
251 t.Run(name, func(t *testing.T) {
252 // Signing the browser out between approval and exchange
253 // refuses the exchange.
254 n := build(t)
255 id, verifier := n.request(t)
256 n.approve(t, id)
257 if res := n.app.db.G.Exec(`DELETE FROM sessions WHERE token_hash = ?`, sessions.HashToken(n.browser.Value)); res.Error != nil || res.RowsAffected != 1 {
258 t.Fatalf("revoke the browser's session: %v", res.Error)
259 }
260 if w := n.exchange(t, id, verifier); w.Code != http.StatusUnauthorized {
261 t.Fatalf("exchange after the browser signed out = %d, want 401", w.Code)
262 }
263
264 // The app signing out a minted session (the app's own
265 // sessions package, deleting a row the pre-move package
266 // wrote) ends it, and leaves the browser's alone.
267 n = build(t)
268 native := n.mint(t)
269 if w := n.do(t, "POST", "/signout", "", native); w.Code >= 400 {
270 t.Fatalf("POST /signout with the minted session = %d", w.Code)
271 }
272 if code, _ := n.defaultCalendar(t, native); code == 200 {
273 t.Fatal("a signed-out minted session still reaches the native API")
274 }
275 if code, _ := n.defaultCalendar(t, n.browser); code != 200 {
276 t.Fatalf("signing native out ended the browser's session too: GET /api/native/session = %d", code)
277 }
278 })
279 }
280}
281