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 "bytes"
5 "encoding/base64"
6 "encoding/json"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "strings"
11 "testing"
12 "time"
13
14 "amadan.net/oficina/home/connect"
15 "amadan.net/rastrillo/rastrillo/assertion"
16 assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
17 "amadan.net/rastrillo/rastrillo/sessions"
18)
19
20const homeTestIssuer = "https://id.example.com"
21
22type homeFixture struct {
23 app *App
24 key *assertioncrypto.Keypair
25 jar map[string]*http.Cookie
26}
27
28func newHomeFixture(t *testing.T) *homeFixture {
29 t.Helper()
30 return newHomeFixtureWith(t, "")
31}
32
33// newHomeFixtureWith is newHomeFixture with the hub directory origin set
34// (HOME_DIRECTORY_ORIGIN), which is what turns the app switcher on.
35func newHomeFixtureWith(t *testing.T, directory string) *homeFixture {
36 t.Helper()
37 app, _ := appWithOrigin(t, testOrigin)
38 key, err := assertioncrypto.Generate()
39 if err != nil {
40 t.Fatal(err)
41 }
42 keys, _ := json.Marshal(map[string]string{"key": base64.StdEncoding.EncodeToString(key.SignPub())})
43 cfg := connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "sheets-preview", KeysBase64: base64.StdEncoding.EncodeToString(keys), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{81}, 32)), Directory: directory}
44 d, err := app.db.DB()
45 if err != nil {
46 t.Fatal(err)
47 }
48 localAuth, err := NewAuth(d, AuthEnv{Origin: testOrigin, InstanceKey: "test-instance-key", Mailer: app.testMailer(t), Home: &cfg}, quietLogger(), app)
49 if err != nil {
50 t.Fatal(err)
51 }
52 t.Cleanup(func() { CloseSigninMail(localAuth) })
53 app.UseAuth(localAuth)
54 if err := app.ConfigureHome(&cfg); err != nil {
55 t.Fatal(err)
56 }
57 return &homeFixture{app: app, key: key, jar: map[string]*http.Cookie{}}
58}
59func (f *homeFixture) request(t *testing.T, method, path string, form url.Values, issuer bool) *httptest.ResponseRecorder {
60 t.Helper()
61 body := ""
62 if form != nil {
63 body = form.Encode()
64 }
65 r := httptest.NewRequest(method, testOrigin+path, strings.NewReader(body))
66 if method == "POST" {
67 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
68 origin := testOrigin
69 if issuer {
70 origin = homeTestIssuer
71 }
72 r.Header.Set("Origin", origin)
73 }
74 if !issuer {
75 for _, cookie := range f.jar {
76 r.AddCookie(cookie)
77 }
78 }
79 w := httptest.NewRecorder()
80 f.app.Router().ServeHTTP(w, r)
81 for _, cookie := range w.Result().Cookies() {
82 if cookie.MaxAge < 0 {
83 delete(f.jar, cookie.Name)
84 } else {
85 f.jar[cookie.Name] = cookie
86 }
87 }
88 return w
89}
90func (f *homeFixture) local(t *testing.T, address string) Member {
91 t.Helper()
92 m, err := f.app.memberFor(address)
93 if err != nil {
94 t.Fatal(err)
95 }
96 token, err := f.app.homeSessions.Mint(sessions.Session{Subject: address, Method: "magiclink"})
97 if err != nil {
98 t.Fatal(err)
99 }
100 f.jar[f.app.homeSessions.CookieName()] = &http.Cookie{Name: f.app.homeSessions.CookieName(), Value: token}
101 return m
102}
103func (f *homeFixture) exchange(t *testing.T, start *httptest.ResponseRecorder, subject string) *httptest.ResponseRecorder {
104 t.Helper()
105 if start.Code != 303 {
106 t.Fatal("start", start.Code, start.Body.String())
107 }
108 target, err := url.Parse(start.Header().Get("Location"))
109 if err != nil {
110 t.Fatal(err)
111 }
112 if target.Host != "id.example.com" || target.Query().Get("consumer") != "sheets-preview" {
113 t.Fatal("wrong Home destination", target.Host)
114 }
115 now := time.Now().Unix()
116 id, _ := assertion.RandomID()
117 token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: homeTestIssuer, Audience: testOrigin, Subject: subject, KeyID: "key", ID: id, Nonce: target.Query().Get("nonce"), IssuedAt: now, ExpiresAt: now + 120, AuthTime: now - 10})
118 if err != nil {
119 t.Fatal(err)
120 }
121 callback := f.request(t, "POST", "/auth/assertion", url.Values{"assertion": {token}}, true)
122 if callback.Code != 303 {
123 t.Fatal("callback", callback.Code, callback.Body.String())
124 }
125 return f.request(t, "GET", callback.Header().Get("Location"), nil, false)
126}
127func (f *homeFixture) link(t *testing.T, subject string) {
128 t.Helper()
129 start := f.request(t, "POST", "/account/home", nil, false)
130 if start.Code != 303 {
131 t.Fatal("link", start.Code, start.Body.String())
132 }
133 authStart := f.request(t, "GET", start.Header().Get("Location"), nil, false)
134 target, _ := url.Parse(authStart.Header().Get("Location"))
135 if target.Query().Get("max_age") != "300" {
136 t.Fatal("link missing fresh Home proof")
137 }
138 finish := f.exchange(t, authStart, subject)
139 if finish.Code != 303 || finish.Header().Get("Location") != "/account/home/confirm" {
140 t.Fatal("finish link", finish.Code, finish.Body.String())
141 }
142 page := f.request(t, "GET", "/account/home/confirm", nil, false)
143 if page.Code != 200 || !strings.Contains(page.Body.String(), "Confirm account connection") {
144 t.Fatal("confirm page", page.Code, page.Body.String())
145 }
146 confirmed := f.request(t, "POST", "/account/home/confirm", nil, false)
147 if confirmed.Code != 303 {
148 t.Fatal("confirm", confirmed.Code, confirmed.Body.String())
149 }
150}
151func TestHomeConnectionPreservesSheetsAndCurrentAdmission(t *testing.T) {
152 f := newHomeFixture(t)
153 member := f.local(t, signedInAddress)
154 sheet, err := f.app.createSheet("Existing linked workbook", member)
155 if err != nil {
156 t.Fatal(err)
157 }
158 f.link(t, "opaque-home-subject")
159 f.jar = map[string]*http.Cookie{}
160 signin := f.request(t, "GET", "/signin?return_to=%2Fsheets", nil, false)
161 start := f.request(t, "GET", signin.Header().Get("Location"), nil, false)
162 finish := f.exchange(t, start, "opaque-home-subject")
163 if finish.Code != 303 || finish.Header().Get("Location") != "/sheets" {
164 t.Fatal("ordinary sign-in", finish.Code, finish.Body.String())
165 }
166 page := f.request(t, "GET", "/sheets", nil, false)
167 if page.Code != 200 || !strings.Contains(page.Body.String(), "Existing linked workbook") || strings.Contains(page.Body.String(), "opaque-home-subject") {
168 t.Fatal("existing sheet missing or subject exposed", page.Code)
169 }
170 var members int64
171 if err := f.app.db.Model(&Member{}).Count(&members).Error; err != nil || members != 1 {
172 t.Fatal("Home created members", members, err)
173 }
174 r := httptest.NewRequest("GET", testOrigin+"/sheets", nil)
175 for _, c := range f.jar {
176 r.AddCookie(c)
177 }
178 actor, ok := f.app.actor(r)
179 if !ok || actor.ID != member.ID {
180 t.Fatal("owner changed")
181 }
182 if !f.app.mayWrite(sheet.ID, actor) {
183 t.Fatal("existing grant changed")
184 }
185 f.app.admit = func(string) bool { return false }
186 if err := f.app.db.Where("member_id = ?", member.ID).Delete(&Grant{}).Error; err != nil {
187 t.Fatal(err)
188 }
189 denied := f.request(t, "GET", "/sheets", nil, false)
190 if denied.Code != 303 || strings.Contains(denied.Body.String(), "Existing linked workbook") {
191 t.Fatal("revoked member admitted", denied.Code)
192 }
193 if _, ok := f.jar[f.app.homeSessions.CookieName()]; ok {
194 t.Fatal("revoked session retained")
195 }
196}
197func TestHomeUnknownAndMalformedFlowsNeverCreateMembers(t *testing.T) {
198 f := newHomeFixture(t)
199 start := f.request(t, "GET", "/auth/start?return_to=%2Fsheets", nil, false)
200 finish := f.exchange(t, start, "unknown-subject")
201 if finish.Code != 403 || !strings.Contains(finish.Body.String(), "/signin/local") {
202 t.Fatal("unknown identity accepted", finish.Code)
203 }
204 var members int64
205 f.app.db.Model(&Member{}).Count(&members)
206 if members != 0 {
207 t.Fatal("Home created local identity", members)
208 }
209 if _, ok := f.jar[f.app.homeSessions.CookieName()]; ok {
210 t.Fatal("unknown identity minted session")
211 }
212 req := httptest.NewRequest("POST", testOrigin+"/account/home", nil)
213 req.Header.Set("Origin", "https://evil.example")
214 rec := httptest.NewRecorder()
215 f.app.Router().ServeHTTP(rec, req)
216 if rec.Code != 403 {
217 t.Fatal("cross-origin link allowed", rec.Code)
218 }
219}
220