oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "net/url"
8 "strings"
9 "testing"
10 "time"
11
12 "amadan.net/oficina/home/handoff"
13 "amadan.net/oficina/home/internal/home/directorydata"
14 "amadan.net/rastrillo/rastrillo/migrate"
15 "amadan.net/rastrillo/rastrillo/sessions"
16)
17
18const chooserPerson = "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa"
19const chooserOther = "pr_bbbbbbbbbbbbbbbbbbbbbbbbbb"
20
21type chooserFixture struct {
22 p exchangePair
23 store *directorydata.Store
24 handler http.Handler
25}
26
27func newChooserFixture(t *testing.T) chooserFixture {
28 t.Helper()
29 p := newExchangePair(t)
30 if _, err := migrate.Apply(context.Background(), p.directory.db, directorydata.Schema); err != nil {
31 t.Fatal(err)
32 }
33 store, err := directorydata.New(p.directory.db.Writer(), p.directory.secrets.Sealer, directorydata.Config{Apps: []string{"docs"}, ValidateRoute: func(r directorydata.Route) bool { return r.Origin == "https://"+r.Label+".docs.oficina.works" }})
34 if err != nil {
35 t.Fatal(err)
36 }
37 c, err := p.directory.newDocsChooser(store)
38 if err != nil {
39 t.Fatal(err)
40 }
41 mux := http.NewServeMux()
42 mux.HandleFunc(docsEntryPath, c.Entry)
43 mux.HandleFunc(docsChoicePath, c.Select)
44 return chooserFixture{p, store, p.directory.serveKnownHost(p.directory.sessions.Middleware(mux))}
45}
46func (f chooserFixture) session(t *testing.T, subject string) *http.Cookie {
47 t.Helper()
48 rec := httptest.NewRecorder()
49 req := httptest.NewRequest("GET", directoryOrigin+"/", nil)
50 if err := f.p.directory.sessions.SignIn(rec, req, sessions.Session{Subject: subject, Method: "assertion", AuthTime: time.Now()}); err != nil {
51 t.Fatal(err)
52 }
53 cookie := sessionCookie(rec)
54 if cookie == nil {
55 t.Fatal("missing session")
56 }
57 return cookie
58}
59func (f chooserFixture) request(method, path, origin, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
60 r := httptest.NewRequest(method, directoryOrigin+path, strings.NewReader(body))
61 if cookie != nil {
62 r.AddCookie(cookie)
63 }
64 if method == "POST" {
65 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
66 }
67 if origin != "" {
68 r.Header.Set("Origin", origin)
69 }
70 rec := httptest.NewRecorder()
71 f.handler.ServeHTTP(rec, r)
72 return rec
73}
74func (f chooserFixture) team(t *testing.T, sqid, label, name, person string) int64 {
75 t.Helper()
76 tx, err := f.p.directory.db.Writer().Begin()
77 if err != nil {
78 t.Fatal(err)
79 }
80 defer tx.Rollback()
81 r, err := tx.Exec(`INSERT INTO teams(sqid,label,sealed_name,created_at) VALUES(?,?,zeroblob(29),1)`, sqid, label)
82 if err != nil {
83 t.Fatal(err)
84 }
85 id, err := r.LastInsertId()
86 if err != nil {
87 t.Fatal(err)
88 }
89 b, err := f.store.SealTeamName(id, name)
90 if err != nil {
91 t.Fatal(err)
92 }
93 for _, q := range []struct {
94 s string
95 a []any
96 }{
97 {`UPDATE teams SET sealed_name=? WHERE id=?`, []any{b, id}},
98 {`INSERT INTO memberships VALUES(?,?,?)`, []any{id, person, "member"}},
99 {`INSERT INTO team_instances VALUES(?,?,?,?,?)`, []any{id, "docs", "https://" + label + ".docs.oficina.works", label + "-docs", "live"}},
100 } {
101 if _, err = tx.Exec(q.s, q.a...); err != nil {
102 t.Fatal(err)
103 }
104 }
105 if err = tx.Commit(); err != nil {
106 t.Fatal(err)
107 }
108 return id
109}
110func noStore(t *testing.T, r *httptest.ResponseRecorder) {
111 t.Helper()
112 if r.Header().Get("Cache-Control") != "no-store" {
113 t.Fatalf("cacheable response %d", r.Code)
114 }
115 if len(r.Result().Cookies()) != 0 {
116 t.Fatal("chooser changed session/navigation cookies")
117 }
118}
119func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) {
120 f := newChooserFixture(t)
121 cookie := f.session(t, chooserPerson)
122 r := f.request("GET", docsEntryPath, "", "", cookie)
123 noStore(t, r)
124 if r.Code != 200 || !strings.Contains(r.Body.String(), "This account has no team with Docs available.") {
125 t.Fatal(r.Code, r.Body.String())
126 }
127 a := f.team(t, "A1", "alpha", "Alpha", chooserPerson)
128 r = f.request("GET", docsEntryPath, "", "", cookie)
129 noStore(t, r)
130 if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/" {
131 t.Fatal(r.Code, r.Header())
132 }
133 f.team(t, "B2", "beta", `<script>alert("team")</script>`, chooserPerson)
134 f.team(t, "C3", "foreign", "Foreign secret label", chooserOther)
135 r = f.request("GET", docsEntryPath, "", "", cookie)
136 noStore(t, r)
137 for _, want := range []string{`method="post" action="/apps/docs/team"`, `name="team" value="A1"`, `name="team" value="B2"`, `/static/theme.css`, `&lt;script&gt;`} {
138 if !strings.Contains(r.Body.String(), want) {
139 t.Fatal("missing", want)
140 }
141 }
142 if r.Code != 200 || hasOtherScript(r.Body.String()) || strings.Contains(r.Body.String(), "Foreign secret label") || strings.Contains(r.Body.String(), chooserPerson) {
143 t.Fatal(r.Code, r.Body.String())
144 }
145 r = f.request("POST", docsChoicePath, directoryOrigin, "team=B2", cookie)
146 noStore(t, r)
147 assertDocsContinuation(t, r, "https://beta.docs.oficina.works/")
148 if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, chooserPerson); err != nil {
149 t.Fatal(err)
150 }
151 for _, team := range []string{"A1", "C3", "Unknown"} {
152 r = f.request("POST", docsChoicePath, directoryOrigin, "team="+team, cookie)
153 noStore(t, r)
154 if r.Code != 404 {
155 t.Fatal(team, r.Code)
156 }
157 }
158 r = f.request("GET", docsEntryPath, "", "", cookie)
159 if r.Header().Get("Location") != "https://beta.docs.oficina.works/" {
160 t.Fatal(r.Header())
161 }
162}
163func TestDocsChooserSignedOutReturnThroughActualHandoff(t *testing.T) {
164 f := newChooserFixture(t)
165 r := f.request("GET", docsEntryPath, "", "", nil)
166 noStore(t, r)
167 if r.Code != 303 || r.Header().Get("Location") != "/auth/start?return_to=%2Fapps%2Fdocs" {
168 t.Fatal(r.Code, r.Header())
169 }
170 start := exchangeRequest(f.p.directory, "GET", directoryOrigin+r.Header().Get("Location"), "", nil)
171 pending := namedCookie(t, start, "__Host-oficina-assertion-pending")
172 authorize := exchangeRequest(f.p.credential.App, "GET", start.Header().Get("Location"), "", nil)
173 intent := namedCookie(t, authorize, assertionIntentCookie)
174 credentialSession := signInFully(t, f.p.credential, "chooser@example.invalid")
175 token, _ := f.p.mint(t, intent, credentialSession)
176 callback := exchangeRequest(f.p.directory, "POST", directoryOrigin+handoff.CallbackPath, Origin, url.Values{"assertion": {token}})
177 inbox := namedCookie(t, callback, "__Host-oficina-assertion-inbox")
178 finish := exchangeRequest(f.p.directory, "GET", directoryOrigin+handoff.FinishPath, "", nil, pending, inbox)
179 if finish.Code != 303 || finish.Header().Get("Location") != docsEntryPath {
180 t.Fatal(finish.Code, finish.Header())
181 }
182 r = f.request("GET", finish.Header().Get("Location"), "", "", sessionCookie(finish))
183 if r.Code != 200 {
184 t.Fatal(r.Code)
185 }
186}
187func TestDocsChooserRefusesHostileInputAndCrossOriginSelection(t *testing.T) {
188 f := newChooserFixture(t)
189 cookie := f.session(t, chooserPerson)
190 f.team(t, "A1", "alpha", "Alpha", chooserPerson)
191 for _, path := range []string{"/apps/docs?team=A1", "/apps/docs?return_to=https://attacker.example", "/apps/docs?", "/apps/docs/team?team=A1"} {
192 method := "GET"
193 if strings.Contains(path, "/team") {
194 method = "POST"
195 }
196 r := f.request(method, path, directoryOrigin, "team=A1", cookie)
197 noStore(t, r)
198 if r.Code != 404 {
199 t.Fatal(path, r.Code)
200 }
201 }
202 for _, body := range []string{"team=A1&team=A1", "team=A1&return_to=%2Fsecret", "team=", "team=%zz", "team=https%3A%2F%2Fattacker.example", strings.Repeat("x", 1025)} {
203 r := f.request("POST", docsChoicePath, directoryOrigin, body, cookie)
204 noStore(t, r)
205 if r.Code != 404 {
206 t.Fatal(body, r.Code)
207 }
208 }
209 for _, origin := range []string{"", "https://attacker.example", Origin} {
210 r := f.request("POST", docsChoicePath, origin, "team=A1", cookie)
211 noStore(t, r)
212 if r.Code != 403 {
213 t.Fatal(origin, r.Code)
214 }
215 }
216 for _, subject := range []string{"user@example.invalid", "A1", chooserOther} {
217 other := f.session(t, subject)
218 r := f.request("POST", docsChoicePath, directoryOrigin, "team=A1", other)
219 noStore(t, r)
220 if r.Code != 404 {
221 t.Fatal(subject, r.Code)
222 }
223 }
224 r := f.request("GET", docsChoicePath, "", "", cookie)
225 noStore(t, r)
226 if r.Code != 405 {
227 t.Fatal(r.Code)
228 }
229 r = f.request("POST", docsEntryPath, directoryOrigin, "team=A1", cookie)
230 noStore(t, r)
231 if r.Code != 405 {
232 t.Fatal(r.Code)
233 }
234}
235func TestDocsChooserFailureIsPrivateWhenMounted(t *testing.T) {
236 f := newChooserFixture(t)
237 cookie := f.session(t, chooserPerson)
238 f.team(t, "A1", "alpha", "Private team name", chooserPerson)
239 if _, err := f.p.directory.db.Writer().Exec(`UPDATE team_instances SET origin='https://unapproved.example'`); err != nil {
240 t.Fatal(err)
241 }
242 r := f.request("GET", docsEntryPath, "", "", cookie)
243 noStore(t, r)
244 if r.Code != 503 || strings.Contains(r.Body.String(), "Private team name") || strings.Contains(r.Body.String(), "unapproved.example") {
245 t.Fatal(r.Code, r.Body.String())
246 }
247 r = exchangeRequest(f.p.directory, "GET", directoryOrigin+docsEntryPath, "", nil, cookie)
248 if r.Code != 503 {
249 t.Fatal("mounted chooser did not preserve storage refusal", r.Code)
250 }
251}
252
253func TestDocsChooserCannotAttachToCredentialRole(t *testing.T) {
254 f := newChooserFixture(t)
255 if _, err := f.p.credential.App.newDocsChooser(f.store); err == nil {
256 t.Fatal("credential role accepted directory renderer")
257 }
258 if _, err := f.p.directory.newDocsChooser(nil); err == nil {
259 t.Fatal("nil store accepted")
260 }
261}
262