oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

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