oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "crypto/rand"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/json"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "strings"
12 "testing"
13 "time"
14
15 "amadan.net/rastrillo/rastrillo/sessions"
16)
17
18// The native sign-in crosses two rastrillo module paths. The app's
19// sessions are amadan.net/rastrillo/rastrillo's; rastrillo/native's
20// server only takes github.com/carlosframework/rastrillo's, which
21// native.go builds over the same database (the one import
22// TestNoPreMoveRastrillo allows). These tests drive the whole exchange
23// through the router, so a change to either package's sessions table,
24// cookie name, token hash or time layout fails here rather than in the
25// Mac app: approval of a browser session, the exchange minting a new
26// one, that one being an ordinary session to the app, and expiry and
27// revocation holding on both sides.
28
29// nativeApp is signInWithOrigin's app with the native routes mounted, as
30// main mounts them, and the browser session's cookies.
31func nativeApp(t *testing.T) (*App, []*http.Cookie) {
32 t.Helper()
33 app, cookies := signInWithOrigin(t)
34 if err := app.ConfigureNative(); err != nil {
35 t.Fatalf("ConfigureNative: %v", err)
36 }
37 return app, cookies
38}
39
40func randomProof(t *testing.T) string {
41 t.Helper()
42 b := make([]byte, 32)
43 if _, err := rand.Read(b); err != nil {
44 t.Fatal(err)
45 }
46 return base64.RawURLEncoding.EncodeToString(b)
47}
48
49// nativeJSONPost is a native client's POST: JSON, from the app's origin.
50func nativeJSONPost(t *testing.T, app *App, path string, body any) *httptest.ResponseRecorder {
51 t.Helper()
52 data, err := json.Marshal(body)
53 if err != nil {
54 t.Fatal(err)
55 }
56 req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(data)))
57 req.Header.Set("Content-Type", "application/json")
58 req.Header.Set("Origin", testOrigin)
59 return do(t, app, req)
60}
61
62// nativeRequest starts a request and returns its id and verifier.
63func nativeRequest(t *testing.T, app *App) (id, verifier string) {
64 t.Helper()
65 verifier = randomProof(t)
66 sum := sha256.Sum256([]byte(verifier))
67 rec := nativeJSONPost(t, app, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(sum[:])})
68 if rec.Code != http.StatusOK {
69 t.Fatalf("POST /native/request = %d: %s", rec.Code, rec.Body.String())
70 }
71 var out struct{ ID string }
72 if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.ID == "" {
73 t.Fatalf("POST /native/request answered %q", rec.Body.String())
74 }
75 return out.ID, verifier
76}
77
78// nativeApprove is the signed-in browser pressing "Sign in to Sheets app".
79func nativeApprove(t *testing.T, app *App, cookies []*http.Cookie, id string) *httptest.ResponseRecorder {
80 t.Helper()
81 req := httptest.NewRequest(http.MethodPost, "/native/authorize?id="+id, nil)
82 req.Header.Set("Origin", testOrigin)
83 return do(t, app, as(req, cookies))
84}
85
86func nativeExchange(t *testing.T, app *App, id, verifier string) *httptest.ResponseRecorder {
87 t.Helper()
88 return nativeJSONPost(t, app, "/native/exchange", map[string]string{"id": id, "verifier": verifier})
89}
90
91// sessionCookie picks the session cookie out of cookies.
92func sessionCookie(t *testing.T, cookies []*http.Cookie) *http.Cookie {
93 t.Helper()
94 for _, c := range cookies {
95 if strings.Contains(c.Name, "session") {
96 return c
97 }
98 }
99 t.Fatalf("no session cookie in %v", cookies)
100 return nil
101}
102
103// listWith is GET /sheets with one session cookie: 200 signed in.
104func listWith(t *testing.T, app *App, c *http.Cookie) int {
105 t.Helper()
106 return do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), []*http.Cookie{c})).Code
107}
108
109// setSessionExpiry rewrites one session row's expiry, by its token.
110func setSessionExpiry(t *testing.T, app *App, token string, at time.Time) {
111 t.Helper()
112 res := app.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token_hash = ?`,
113 at.UTC().Format(time.RFC3339), sessions.HashToken(token))
114 if res.Error != nil || res.RowsAffected != 1 {
115 t.Fatalf("expire session: %v, %d rows", res.Error, res.RowsAffected)
116 }
117}
118
119func TestNativeExchangeMintsASessionTheAppAccepts(t *testing.T) {
120 app, cookies := nativeApp(t)
121 browser := sessionCookie(t, cookies)
122 create := postForm("/sheets", url.Values{"title": {"Native probe"}})
123 create.Header.Set("Origin", testOrigin)
124 if rec := do(t, app, as(create, cookies)); rec.Code != http.StatusSeeOther {
125 t.Fatalf("creating a sheet = %d: %s", rec.Code, rec.Body.String())
126 }
127 id, verifier := nativeRequest(t, app)
128
129 if rec := nativeExchange(t, app, id, verifier); rec.Code != http.StatusAccepted {
130 t.Fatalf("exchange before approval = %d, want 202", rec.Code)
131 }
132 if rec := nativeApprove(t, app, cookies, id); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "is signed in") {
133 t.Fatalf("approval = %d: %s", rec.Code, rec.Body.String())
134 }
135 if rec := nativeExchange(t, app, id, randomProof(t)); rec.Code != http.StatusNotFound {
136 t.Fatalf("exchange with the wrong verifier = %d, want 404", rec.Code)
137 }
138 rec := nativeExchange(t, app, id, verifier)
139 if rec.Code != http.StatusOK {
140 t.Fatalf("exchange = %d: %s", rec.Code, rec.Body.String())
141 }
142 var minted struct{ Name, Value string }
143 if err := json.Unmarshal(rec.Body.Bytes(), &minted); err != nil {
144 t.Fatalf("exchange answered %q", rec.Body.String())
145 }
146 // The pre-move package names the cookie the app reads, and mints a
147 // new token rather than handing back the browser's.
148 if minted.Name != browser.Name {
149 t.Fatalf("exchange minted cookie %q; the app reads %q", minted.Name, browser.Name)
150 }
151 if minted.Value == "" || minted.Value == browser.Value {
152 t.Fatal("exchange did not mint a session of its own")
153 }
154 native := &http.Cookie{Name: minted.Name, Value: minted.Value}
155 if code := listWith(t, app, native); code != http.StatusOK {
156 t.Fatalf("the app refuses the session native minted: GET /sheets = %d", code)
157 }
158 // And it is the same person: their sheet is in the list.
159 list := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), []*http.Cookie{native}))
160 if !strings.Contains(list.Body.String(), "Native probe") {
161 t.Fatal("the minted session is somebody else: the browser's sheet is not in its list")
162 }
163 // Spent: the same id never exchanges twice.
164 if rec := nativeExchange(t, app, id, verifier); rec.Code != http.StatusNotFound {
165 t.Fatalf("second exchange = %d, want 404", rec.Code)
166 }
167}
168
169func TestNativeSessionExpiryHolds(t *testing.T) {
170 app, cookies := nativeApp(t)
171 browser := sessionCookie(t, cookies)
172
173 // A browser session that has expired cannot approve.
174 id, _ := nativeRequest(t, app)
175 setSessionExpiry(t, app, browser.Value, time.Now().Add(-time.Minute))
176 if rec := nativeApprove(t, app, cookies, id); rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "is signed in") {
177 t.Fatal("an expired browser session approved a native sign-in")
178 }
179
180 // Approved while live, expired before the exchange: refused.
181 setSessionExpiry(t, app, browser.Value, time.Now().Add(time.Hour))
182 id, verifier := nativeRequest(t, app)
183 nativeApprove(t, app, cookies, id)
184 setSessionExpiry(t, app, browser.Value, time.Now().Add(-time.Minute))
185 if rec := nativeExchange(t, app, id, verifier); rec.Code != http.StatusUnauthorized {
186 t.Fatalf("exchange of an expired approval = %d, want 401", rec.Code)
187 }
188
189 // A minted session expires like any other.
190 setSessionExpiry(t, app, browser.Value, time.Now().Add(time.Hour))
191 id, verifier = nativeRequest(t, app)
192 nativeApprove(t, app, cookies, id)
193 rec := nativeExchange(t, app, id, verifier)
194 var minted struct{ Name, Value string }
195 if err := json.Unmarshal(rec.Body.Bytes(), &minted); err != nil {
196 t.Fatalf("exchange = %d %q", rec.Code, rec.Body.String())
197 }
198 native := &http.Cookie{Name: minted.Name, Value: minted.Value}
199 if code := listWith(t, app, native); code != http.StatusOK {
200 t.Fatalf("live minted session: GET /sheets = %d", code)
201 }
202 setSessionExpiry(t, app, minted.Value, time.Now().Add(-time.Minute))
203 if code := listWith(t, app, native); code == http.StatusOK {
204 t.Fatal("the app still accepts a minted session past its expiry")
205 }
206}
207
208func TestNativeSessionRevocationHolds(t *testing.T) {
209 app, cookies := nativeApp(t)
210 browser := sessionCookie(t, cookies)
211
212 // Signing the browser out between approval and exchange refuses it.
213 id, verifier := nativeRequest(t, app)
214 nativeApprove(t, app, cookies, id)
215 if res := app.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, sessions.HashToken(browser.Value)); res.Error != nil || res.RowsAffected != 1 {
216 t.Fatalf("revoke browser session: %v", res.Error)
217 }
218 if rec := nativeExchange(t, app, id, verifier); rec.Code != http.StatusUnauthorized {
219 t.Fatalf("exchange after the browser signed out = %d, want 401", rec.Code)
220 }
221
222 // The app signing out a minted session (the app's own sessions
223 // package, deleting a row the pre-move package wrote) ends it, and
224 // leaves the browser's alone.
225 app, cookies = nativeApp(t)
226 browser = sessionCookie(t, cookies)
227 id, verifier = nativeRequest(t, app)
228 nativeApprove(t, app, cookies, id)
229 var minted struct{ Name, Value string }
230 if rec := nativeExchange(t, app, id, verifier); json.Unmarshal(rec.Body.Bytes(), &minted) != nil {
231 t.Fatalf("exchange = %d %q", rec.Code, rec.Body.String())
232 }
233 native := &http.Cookie{Name: minted.Name, Value: minted.Value}
234 out := httptest.NewRequest(http.MethodPost, "/signout", nil)
235 out.Header.Set("Origin", testOrigin)
236 if rec := do(t, app, as(out, []*http.Cookie{native})); rec.Code >= 400 {
237 t.Fatalf("POST /signout with the minted session = %d", rec.Code)
238 }
239 if code := listWith(t, app, native); code == http.StatusOK {
240 t.Fatal("a signed-out minted session still reaches /sheets")
241 }
242 if code := listWith(t, app, browser); code != http.StatusOK {
243 t.Fatalf("signing native out ended the browser's session too: GET /sheets = %d", code)
244 }
245}
246