oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1package slides
2
3import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "errors"
9 "html"
10 "net/http"
11 "net/http/httptest"
12 "net/url"
13 "regexp"
14 "strings"
15 "testing"
16 "time"
17
18 "amadan.net/oficina/home/connect"
19 "amadan.net/oficina/home/connect/linkstore"
20 "amadan.net/oficina/home/handoff"
21 "amadan.net/rastrillo/rastrillo"
22 "amadan.net/rastrillo/rastrillo/assertion"
23 assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
24 "amadan.net/rastrillo/rastrillo/sessions"
25 "gorm.io/gorm"
26)
27
28const homeTestOrigin = "https://slides.example.com"
29
30// confirmForm is the confirm door's one form, as a browser would read it
31// (signin.html's Confirm branch, renderConfirm).
32var confirmForm = regexp.MustCompile(`<form[^>]*method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]+)">`)
33
34func homeFixture(t *testing.T) (*http.ServeMux, *app, User) {
35 mux, a, u, _ := homeFixtureKey(t)
36 return mux, a, u
37}
38func homeFixtureKey(t *testing.T) (*http.ServeMux, *app, User, *assertioncrypto.Keypair) {
39 t.Helper()
40 d := testDB(t)
41 key, err := assertioncrypto.Generate()
42 if err != nil {
43 t.Fatal(err)
44 }
45 raw, _ := json.Marshal(map[string]string{"test": base64.StdEncoding.EncodeToString(key.SignPub())})
46 cfg := Config{Mailer: &homeTestMail{}, Env: EnvDevelopment, Origin: homeTestOrigin, Logger: quietLogger(), AllowedAddresses: []string{"owner@example.com"}, Home: &connect.Credentials{Issuer: "https://home.example.com", ConsumerID: "slides-test", KeysBase64: base64.StdEncoding.EncodeToString(raw), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))}}
47 mux, a, err := New(d, cfg)
48 if err != nil {
49 t.Fatal(err)
50 }
51 t.Cleanup(func() { a.Close() })
52 u := User{ID: 72, Email: "owner@example.com", Name: "Existing owner", Colour: "#112233"}
53 if err := d.G.Create(&u).Error; err != nil {
54 t.Fatal(err)
55 }
56 return mux, a, u, key
57}
58func homeSession(t *testing.T, a *app, subject, method string) *http.Cookie {
59 t.Helper()
60 w := httptest.NewRecorder()
61 r := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
62 if err := a.homeSessions.SignIn(w, r, sessions.Session{Subject: subject, Method: method, AuthTime: time.Now()}); err != nil {
63 t.Fatal(err)
64 }
65 for _, c := range w.Result().Cookies() {
66 if c.Name == a.homeSessions.CookieName() {
67 return c
68 }
69 }
70 t.Fatal("missing session cookie")
71 return nil
72}
73func homeLinked(t *testing.T, a *app, u User) {
74 t.Helper()
75 ctx := context.Background()
76 now := time.Now()
77 c, err := connect.DecodeCredentials(a.cfg.Home, a.cfg.Origin)
78 if err != nil {
79 t.Fatal(err)
80 }
81 store, err := linkstore.New(a.db.Writer(), c.DEK, a.cfg.Origin, c.Issuer, "oficina/slides", func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) {
82 var address string
83 err := q.QueryRowContext(ctx, "SELECT email FROM users WHERE id=?", id).Scan(&address)
84 return address, err
85 })
86 if err != nil {
87 t.Fatal(err)
88 }
89 local := linkstore.Local{PersonID: u.ID, SessionToken: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{17}, 32)), AuthTime: now}
90 nonce := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{19}, 16))
91 init, err := store.Start(ctx, local, "/", now)
92 if err != nil {
93 t.Fatal(err)
94 }
95 if err := store.BindNonce(ctx, init, local, nonce, now); err != nil {
96 t.Fatal(err)
97 }
98 proof, err := store.Complete(ctx, init, local, linkstore.Home{Issuer: c.Issuer, Subject: "home-person", Nonce: nonce, AuthTime: now}, now)
99 if err != nil {
100 t.Fatal(err)
101 }
102 if _, err := store.Confirm(ctx, proof, local, now); err != nil {
103 t.Fatal(err)
104 }
105}
106
107func TestHomeExistingIdentityNoCreationAndStableActors(t *testing.T) {
108 mux, a, u := homeFixture(t)
109 homeLinked(t, a, u)
110 cookie := homeSession(t, a, "home-person", connect.HomeMethod)
111 r := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
112 r.AddCookie(cookie)
113 w := httptest.NewRecorder()
114 mux.ServeHTTP(w, r)
115 if w.Code != 200 {
116 t.Fatalf("home root: %d %s", w.Code, w.Body.String())
117 }
118 var users, decks int64
119 a.db.G.Model(&User{}).Count(&users)
120 a.db.G.Model(&Deck{}).Count(&decks)
121 if users != 1 || decks != 0 {
122 t.Fatalf("Home created rows users=%d decks=%d", users, decks)
123 }
124 if got := a.presenterActor(r); got != actorFor(u) {
125 t.Fatalf("actor %q", got)
126 }
127 s, _ := a.homeSessions.From(r)
128 r = sessions.WithSession(r, s)
129 principal, ok := a.sessionSubject(httptest.NewRecorder(), r)
130 if !ok || principal != u.Email {
131 t.Fatal("export owner changed")
132 }
133 jobsRequest := a.exportPrincipal(r)
134 jobsSession, _ := sessions.Current(jobsRequest)
135 if jobsSession.Subject != u.Email {
136 t.Fatal("job lookup owner changed")
137 }
138 restored, _ := sessions.Current(restoreExportPrincipal(jobsRequest))
139 if restored.Subject != "home-person" || restored.Method != connect.HomeMethod {
140 t.Fatal("job renderer changed verified identity")
141 }
142}
143func TestHomeUnknownSubjectNeverMatchesEmailOrSeeds(t *testing.T) {
144 mux, a, u := homeFixture(t)
145 r := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
146 r.AddCookie(homeSession(t, a, u.Email, connect.HomeMethod))
147 w := httptest.NewRecorder()
148 mux.ServeHTTP(w, r)
149 if w.Code == 200 {
150 t.Fatal("Home subject matched existing email without explicit link")
151 }
152 var n int64
153 a.db.G.Model(&User{}).Count(&n)
154 if n != 1 {
155 t.Fatal("Home created user")
156 }
157 a.db.G.Model(&Deck{}).Count(&n)
158 if n != 0 {
159 t.Fatal("Home seeded content")
160 }
161}
162func TestHomeOffboardingRevokesBothMethodsAndEndsPresentation(t *testing.T) {
163 mux, a, u := homeFixture(t)
164 homeLinked(t, a, u)
165 home := homeSession(t, a, "home-person", connect.HomeMethod)
166 _ = homeSession(t, a, u.Email, "magiclink")
167 p := Presentation{Sqid: "testtalk", Actor: actorFor(u), StartedAt: time.Now()}
168 if err := a.db.G.Create(&p).Error; err != nil {
169 t.Fatal(err)
170 }
171 a.allowed = allowlist{addrs: map[string]struct{}{}}
172 r := httptest.NewRequest("POST", homeTestOrigin+"/decks", strings.NewReader("Title=unexpected"))
173 r.Header.Set("Origin", homeTestOrigin)
174 r.AddCookie(home)
175 w := httptest.NewRecorder()
176 mux.ServeHTTP(w, r)
177 if w.Code != 403 {
178 t.Fatalf("removed POST: %d", w.Code)
179 }
180 var n int64
181 a.db.G.Table("sessions").Count(&n)
182 if n != 0 {
183 t.Fatal("offboarding retained sessions", n)
184 }
185 a.db.G.First(&p, p.ID)
186 if p.EndedAt == nil {
187 t.Fatal("removed presenter stayed live")
188 }
189 a.db.G.Model(&User{}).Count(&n)
190 if n != 1 {
191 t.Fatal("offboarding deleted account")
192 }
193}
194func TestHomeFrontDoorLocalRecoveryAndCallbackGuard(t *testing.T) {
195 mux, a, _ := homeFixture(t)
196 for _, tc := range []struct {
197 method, path string
198 status int
199 }{{"GET", "/signin?return_to=%2Fd%2Fexisting", 303}, {"GET", "/signin/local", 200}, {"GET", "/account/home", 303}, {"POST", "/account/home", 403}, {"POST", handoff.CallbackPath, 400}, {"GET", "/signed-out", 200}} {
200 r := httptest.NewRequest(tc.method, homeTestOrigin+tc.path, nil)
201 w := httptest.NewRecorder()
202 mux.ServeHTTP(w, r)
203 if w.Code != tc.status {
204 t.Errorf("%s %s status %d body %s", tc.method, tc.path, w.Code, w.Body.String())
205 }
206 if tc.path == "/signin/local" && !strings.Contains(w.Body.String(), "estilo-signin-form") {
207 t.Error("local form missing")
208 }
209 // connect's "signedout" state draws the door's signed-out page,
210 // never connect's own "Sign in with Home".
211 if tc.path == "/signed-out" && (!strings.Contains(w.Body.String(), "You’re signed out") || strings.Contains(w.Body.String(), "with Home")) {
212 t.Errorf("Home-mode signed-out page is not the door's:\n%s", w.Body.String())
213 }
214 }
215 r := httptest.NewRequest("GET", homeTestOrigin+"/signin/local", nil)
216 c := homeSession(t, a, "home-person", connect.HomeMethod)
217 r.AddCookie(c)
218 r.AddCookie(c)
219 w := httptest.NewRecorder()
220 mux.ServeHTTP(w, r)
221 if w.Code != 400 {
222 t.Fatal("duplicate session bypassed guard")
223 }
224 if !strings.Contains(w.Header().Get("Content-Security-Policy"), "form-action 'self' https://home.example.com") {
225 t.Fatal("Home POST continuation omitted from CSP")
226 }
227}
228
229// TestHomeDifferentAddressStaysInLocalRecovery keeps the recovery route
230// separate from Home's front door. The sent page is reached at /signin, but
231// its retry must return to /signin/local with the same safe destination so a
232// second submission still completes the local recovery.
233func TestHomeDifferentAddressStaysInLocalRecovery(t *testing.T) {
234 mux, _, u := homeFixture(t)
235 jar := map[string]*http.Cookie{}
236 do := func(method, path string, form url.Values) *httptest.ResponseRecorder {
237 r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(form.Encode()))
238 r.Header.Set("Accept", "text/html")
239 if method == http.MethodPost {
240 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
241 r.Header.Set("Origin", homeTestOrigin)
242 r.Header.Set("Sec-Fetch-Site", "same-origin")
243 }
244 for _, c := range jar {
245 r.AddCookie(c)
246 }
247 w := httptest.NewRecorder()
248 mux.ServeHTTP(w, r)
249 for _, c := range w.Result().Cookies() {
250 if c.MaxAge < 0 {
251 delete(jar, c.Name)
252 } else {
253 jar[c.Name] = c
254 }
255 }
256 return w
257 }
258
259 const deep = "/d/keep-going?from=signin"
260 if first := do(http.MethodGet, "/signin/local?return_to="+url.QueryEscape(deep), nil); first.Code != http.StatusOK {
261 t.Fatalf("local form: %d", first.Code)
262 }
263 sent := do(http.MethodPost, signinPath, url.Values{"address": {u.Email}})
264 if sent.Code != http.StatusSeeOther || sent.Header().Get("Location") != "/signin?sent=1" {
265 t.Fatalf("first submission: %d %q", sent.Code, sent.Header().Get("Location"))
266 }
267 sentPage := do(http.MethodGet, sent.Header().Get("Location"), nil)
268 if sentPage.Code != http.StatusOK {
269 t.Fatalf("sent page: %d", sentPage.Code)
270 }
271 m := regexp.MustCompile(`href="([^"]+)">Use a different address`).FindStringSubmatch(sentPage.Body.String())
272 if m == nil {
273 t.Fatal("sent page has no Use a different address link")
274 }
275 again := strings.ReplaceAll(m[1], "&amp;", "&")
276 wantAgain := "/signin/local?return_to=" + url.QueryEscape(deep)
277 if again != wantAgain {
278 t.Fatalf("retry link = %q, want %q", again, wantAgain)
279 }
280 retry := do(http.MethodGet, again, nil)
281 if retry.Code != http.StatusOK || !strings.Contains(retry.Body.String(), `name="return_to" value="`+deep+`"`) {
282 t.Fatalf("retry form lost return_to: %d\n%s", retry.Code, retry.Body.String())
283 }
284 second := do(http.MethodPost, signinPath, url.Values{"address": {u.Email}, "return_to": {deep}})
285 if second.Code != http.StatusSeeOther || second.Header().Get("Location") != "/signin?sent=1" {
286 t.Fatalf("second submission did not retain the local flow: %d %q", second.Code, second.Header().Get("Location"))
287 }
288}
289func TestHomeInvalidConfigurationDoesNotMigrate(t *testing.T) {
290 d := testDB(t)
291 _, _, err := New(d, Config{Env: EnvDevelopment, Origin: homeTestOrigin, Home: &connect.Credentials{Issuer: "https://home.example.com"}})
292 if err == nil {
293 t.Fatal("partial config accepted")
294 }
295 var n int
296 if err := d.Writer().QueryRowContext(context.Background(), "SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&n); err != nil || n != 0 {
297 t.Fatal("invalid configuration changed database", n, err)
298 }
299}
300
301func TestHomeRealSignedExchangeExplicitLinkAndReplay(t *testing.T) {
302 mux, a, u, key := homeFixtureKey(t)
303 jar := map[string]*http.Cookie{}
304 local := homeSession(t, a, u.Email, "magiclink")
305 jar[local.Name] = local
306 call := func(method, path, origin, form string) *httptest.ResponseRecorder {
307 r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(form))
308 if origin != "" {
309 r.Header.Set("Origin", origin)
310 }
311 if form != "" {
312 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
313 }
314 for _, c := range jar {
315 r.AddCookie(c)
316 }
317 w := httptest.NewRecorder()
318 mux.ServeHTTP(w, r)
319 for _, c := range w.Result().Cookies() {
320 if c.MaxAge < 0 {
321 delete(jar, c.Name)
322 } else {
323 jar[c.Name] = c
324 }
325 }
326 return w
327 }
328 start := call("POST", "/account/home", homeTestOrigin, "")
329 if start.Code != 303 {
330 t.Fatal("start", start.Code, start.Body.String())
331 }
332 auth := call("GET", start.Header().Get("Location"), "", "")
333 target, err := url.Parse(auth.Header().Get("Location"))
334 if err != nil || target.Query().Get("max_age") != "300" {
335 t.Fatal("fresh Home request absent", auth.Code, auth.Header())
336 }
337 id, _ := assertion.RandomID()
338 now := time.Now().Unix()
339 token, err := assertion.Sign(key, assertion.Claims{Version: 1, Issuer: a.cfg.Home.Issuer, Audience: homeTestOrigin, Subject: "linked-home-person", KeyID: "test", ID: id, Nonce: target.Query().Get("nonce"), IssuedAt: now, ExpiresAt: now + 90, AuthTime: now})
340 if err != nil {
341 t.Fatal(err)
342 }
343 callback := call("POST", handoff.CallbackPath, a.cfg.Home.Issuer, url.Values{"assertion": {token}}.Encode())
344 if callback.Code != 303 {
345 t.Fatal("signed callback", callback.Code, callback.Body.String())
346 }
347 finish := call("GET", handoff.FinishPath, "", "")
348 if finish.Code != 303 || finish.Header().Get("Location") != "/account/home/confirm" {
349 t.Fatal("finish", finish.Code, finish.Header(), finish.Body.String())
350 }
351 var links int64
352 a.db.G.Table("home_identity_links").Count(&links)
353 if links != 0 {
354 t.Fatal("link before explicit confirmation")
355 }
356 confirm := call("POST", "/account/home/confirm", homeTestOrigin, "")
357 if confirm.Code != 303 {
358 t.Fatal("confirm", confirm.Code, confirm.Body.String())
359 }
360 person, err := a.home.Resolve(context.Background(), "linked-home-person")
361 if err != nil || person.ID != u.ID {
362 t.Fatal("existing identity lost", person, err)
363 }
364 if jar[a.homeSessions.CookieName()].Value != local.Value {
365 t.Fatal("link silently replaced local session")
366 }
367 replay := call("POST", handoff.CallbackPath, a.cfg.Home.Issuer, url.Values{"assertion": {token}}.Encode())
368 if replay.Code < 400 {
369 t.Fatal("assertion replay accepted", replay.Code)
370 }
371 var users, decks int64
372 a.db.G.Model(&User{}).Count(&users)
373 a.db.G.Model(&Deck{}).Count(&decks)
374 if users != 1 || decks != 0 {
375 t.Fatal("link created data", users, decks)
376 }
377}
378
379func TestHomeDeletedLocalIDCannotBeReused(t *testing.T) {
380 _, a, u := homeFixture(t)
381 homeLinked(t, a, u)
382 if err := a.db.G.Delete(&u).Error; err != nil {
383 t.Fatal(err)
384 }
385 replacement := User{Email: u.Email}
386 if err := a.db.G.Create(&replacement).Error; err != nil {
387 t.Fatal(err)
388 }
389 if replacement.ID <= u.ID {
390 t.Fatal("user IDs may be reused", replacement.ID, u.ID)
391 }
392 if _, err := a.home.Resolve(context.Background(), "home-person"); err == nil {
393 t.Fatal("deleted user's link reassigned to replacement")
394 }
395}
396
397// TestHomePolicyTracksActualFrameworkAndStandalone: with Home, every
398// page's policy is connect.FormPolicy — Slides' own policy with the
399// issuer added to form-action, and nothing else changed — and standalone
400// it is Slides' own policy, through the same ServeOptions main uses.
401// Slides' policy is rastrillo's default bar style-src
402// (TestProductionCSPIsTheDefaultBarStyles in serving_test.go).
403func TestHomePolicyTracksActualFrameworkAndStandalone(t *testing.T) {
404 policy := ContentSecurityPolicy
405 mux, a, _ := homeFixture(t)
406 want := strings.Replace(policy, "form-action 'self'", "form-action 'self' "+a.cfg.Home.Issuer, 1)
407 if want == policy {
408 t.Fatal("framework form policy changed")
409 }
410 wrapped, closeHome, err := rastrillo.Handler(a.ServeOptions(rastrillo.Options{}, mux))
411 if err != nil {
412 t.Fatal(err)
413 }
414 defer closeHome()
415 for _, path := range []string{"/signin/local", "/account/home", "/auth/start", "/signed-out"} {
416 rec := httptest.NewRecorder()
417 wrapped.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+path, nil))
418 if got := rec.Header().Get("Content-Security-Policy"); got != want {
419 t.Fatal("Home CSP drift", path, got, want)
420 }
421 }
422 plain, app, err := New(testDB(t), Config{Env: EnvDevelopment, Origin: homeTestOrigin})
423 if err != nil {
424 t.Fatal(err)
425 }
426 defer app.Close()
427 standalone, closePlain, err := rastrillo.Handler(app.ServeOptions(rastrillo.Options{}, plain))
428 if err != nil {
429 t.Fatal(err)
430 }
431 defer closePlain()
432 rec := httptest.NewRecorder()
433 standalone.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+"/signin", nil))
434 if got := rec.Header().Values("Content-Security-Policy"); len(got) != 1 || got[0] != policy {
435 t.Fatal("standalone CSP changed", got)
436 }
437}
438
439type homeTestMail struct{ body string }
440
441func (m *homeTestMail) Send(_ context.Context, _, _ string, body string) error {
442 m.body = body
443 return nil
444}
445func TestHomeLocalPluginRecoveryRotatesThenReturnsToLink(t *testing.T) {
446 mux, a, u := homeFixture(t)
447 old := homeSession(t, a, "unlinked-home", connect.HomeMethod)
448 jar := []*http.Cookie{old}
449 run := func(method, path, form string) *httptest.ResponseRecorder {
450 r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(form))
451 for _, c := range jar {
452 r.AddCookie(c)
453 }
454 if method == "POST" {
455 r.Header.Set("Origin", homeTestOrigin)
456 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
457 }
458 w := httptest.NewRecorder()
459 mux.ServeHTTP(w, r)
460 return w
461 }
462 begin := run("GET", "/account/home", "")
463 if begin.Code != 303 || begin.Header().Get("Location") != "/signin/local" {
464 t.Fatal("recovery did not begin", begin.Code)
465 }
466 for _, c := range begin.Result().Cookies() {
467 if c.MaxAge > 0 {
468 jar = append(jar, c)
469 }
470 }
471 sent := run("POST", "/signin", url.Values{"address": {u.Email}}.Encode())
472 if sent.Code != 303 {
473 t.Fatal(sent.Code)
474 }
475 a.FlushMail()
476 body := a.cfg.Mailer.(*homeTestMail).body
477 link := regexp.MustCompile(`https://[^\s<>]+/auth/verify\?token=[A-Za-z0-9_-]+`).FindString(body)
478 if link == "" {
479 t.Fatal("local fixture link missing")
480 }
481 parsed, _ := url.Parse(link)
482 // Opening the link draws the confirm door under LocalGuard and
483 // spends nothing; its Sign in button POSTs the token back, and that
484 // POST is the local verification.
485 open := run("GET", parsed.RequestURI(), "")
486 m := confirmForm.FindStringSubmatch(open.Body.String())
487 if open.Code != 200 || m == nil || !strings.Contains(open.Body.String(), "estilo-door") || len(open.Result().Cookies()) != 0 {
488 t.Fatal("Home-local link did not open the confirm door", open.Code, open.Body.String())
489 }
490 verify := run("POST", html.UnescapeString(m[1]), url.Values{"token": {html.UnescapeString(m[2])}}.Encode())
491 if verify.Code != 303 || verify.Header().Get("Location") != connect.LocalCompletePath {
492 t.Fatal("local verify did not reach continuation", verify.Code, verify.Header())
493 }
494 for _, c := range verify.Result().Cookies() {
495 if c.Name == old.Name && c.MaxAge >= 0 {
496 jar[0] = c
497 }
498 }
499 if jar[0].Value == old.Value {
500 t.Fatal("local verification retained old session")
501 }
502 stale := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
503 stale.AddCookie(old)
504 if _, ok := a.homeSessions.From(stale); ok {
505 t.Fatal("old session not revoked")
506 }
507 done := run("GET", connect.LocalCompletePath, "")
508 if done.Code != 303 || done.Header().Get("Location") != "/account/home" {
509 t.Fatal("recovery lost link navigation", done.Code, done.Header())
510 }
511 var n int64
512 a.db.G.Model(&User{}).Count(&n)
513 if n != 1 {
514 t.Fatal("recovery created user")
515 }
516 a.db.G.Model(&Deck{}).Count(&n)
517 if n != 0 {
518 t.Fatal("recovery seeded deck")
519 }
520}
521
522func TestHomeTransientReadFailureKeepsAllSessions(t *testing.T) {
523 mux, a, u := homeFixture(t)
524 homeLinked(t, a, u)
525 cookie := homeSession(t, a, "home-person", connect.HomeMethod)
526 _ = homeSession(t, a, "home-person", connect.HomeMethod)
527 if err := a.db.G.Callback().Query().Before("gorm:query").Register("home_test:transient", func(tx *gorm.DB) {
528 if tx.Statement.Table == "users" {
529 tx.AddError(errors.New("synthetic transient read failure"))
530 }
531 }); err != nil {
532 t.Fatal(err)
533 }
534 r := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
535 r.AddCookie(cookie)
536 w := httptest.NewRecorder()
537 mux.ServeHTTP(w, r)
538 if w.Code != 500 {
539 t.Fatal("storage read did not fail", w.Code)
540 }
541 var n int64
542 a.db.G.Table("sessions").Count(&n)
543 if n != 2 {
544 t.Fatal("transient read revoked sessions", n)
545 }
546 for _, c := range w.Result().Cookies() {
547 if c.Name == cookie.Name {
548 t.Fatal("transient read mutated session cookie")
549 }
550 }
551}
552func TestHomeFinishRemovedPersonIsAdmissionRefusal(t *testing.T) {
553 for _, remove := range []string{"deleted", "denied"} {
554 t.Run(remove, func(t *testing.T) {
555 mux, a, u, key := homeFixtureKey(t)
556 homeLinked(t, a, u)
557 start := httptest.NewRecorder()
558 mux.ServeHTTP(start, httptest.NewRequest("GET", homeTestOrigin+handoff.StartPath, nil))
559 target, _ := url.Parse(start.Header().Get("Location"))
560 if start.Code != 303 {
561 t.Fatal("start", start.Code)
562 }
563 now := time.Now().Unix()
564 id, _ := assertion.RandomID()
565 token, err := assertion.Sign(key, assertion.Claims{Version: 1, Issuer: a.cfg.Home.Issuer, Audience: homeTestOrigin, Subject: "home-person", KeyID: "test", ID: id, Nonce: target.Query().Get("nonce"), IssuedAt: now, ExpiresAt: now + 90, AuthTime: now})
566 if err != nil {
567 t.Fatal(err)
568 }
569 callbackReq := httptest.NewRequest("POST", homeTestOrigin+handoff.CallbackPath, strings.NewReader(url.Values{"assertion": {token}}.Encode()))
570 callbackReq.Header.Set("Origin", a.cfg.Home.Issuer)
571 callbackReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
572 callback := httptest.NewRecorder()
573 mux.ServeHTTP(callback, callbackReq)
574 if callback.Code != 303 {
575 t.Fatal("callback", callback.Code)
576 }
577 if remove == "deleted" {
578 if err := a.db.G.Delete(&u).Error; err != nil {
579 t.Fatal(err)
580 }
581 } else {
582 a.allowed = allowlist{addrs: map[string]struct{}{}}
583 }
584 finishReq := httptest.NewRequest("GET", homeTestOrigin+handoff.FinishPath, nil)
585 for _, response := range []*httptest.ResponseRecorder{start, callback} {
586 for _, c := range response.Result().Cookies() {
587 if c.MaxAge >= 0 {
588 finishReq.AddCookie(c)
589 }
590 }
591 }
592 finish := httptest.NewRecorder()
593 mux.ServeHTTP(finish, finishReq)
594 if finish.Code != 403 {
595 t.Fatal("admission became storage failure", finish.Code, finish.Body.String())
596 }
597 for _, c := range finish.Result().Cookies() {
598 if c.Name == a.homeSessions.CookieName() {
599 t.Fatal("removed person received session")
600 }
601 }
602 })
603 }
604}
605
606func TestHomeEditorPresentationAndExportUseExistingOwner(t *testing.T) {
607 mux, a, u := homeFixture(t)
608 homeLinked(t, a, u)
609 a.seedSampleDeckFor(context.Background(), u)
610 var deck Deck
611 if err := a.db.G.Where("owner_id=?", u.ID).Take(&deck).Error; err != nil {
612 t.Fatal(err)
613 }
614 home := homeSession(t, a, "home-person", connect.HomeMethod)
615 local := homeSession(t, a, u.Email, "magiclink")
616 request := func(method, path string, cookie *http.Cookie) *httptest.ResponseRecorder {
617 r := httptest.NewRequest(method, homeTestOrigin+path, nil)
618 r.AddCookie(cookie)
619 if method == "POST" {
620 r.Header.Set("Origin", homeTestOrigin)
621 }
622 w := httptest.NewRecorder()
623 mux.ServeHTTP(w, r)
624 return w
625 }
626 base := "/d/" + deck.Sqid
627 for _, path := range []string{base + "/edit", base + "/updates?since=0", "/agents"} {
628 if w := request("GET", path, home); w.Code != 200 {
629 t.Fatal("Home route", path, w.Code)
630 }
631 }
632 present := request("POST", base+"/present", home)
633 if present.Code != 303 {
634 t.Fatal("present", present.Code)
635 }
636 if notes := request("GET", present.Header().Get("Location"), home); notes.Code != 200 {
637 t.Fatal("Home presenter lost access", notes.Code)
638 }
639 export := request("POST", base+"/export", home)
640 if export.Code != 303 {
641 t.Fatal("export", export.Code)
642 }
643 status := export.Header().Get("Location")
644 if page := request("GET", status, home); page.Code != 200 {
645 t.Fatal("Home job status", page.Code, page.Body.String())
646 }
647 if page := request("GET", status, local); page.Code != 200 {
648 t.Fatal("local method lost same owner's job", page.Code)
649 }
650 deadline := time.Now().Add(30 * time.Second)
651 for time.Now().Before(deadline) {
652 pdf := request("GET", status+".pdf", home)
653 if pdf.Code == 200 {
654 if !bytes.HasPrefix(pdf.Body.Bytes(), []byte("%PDF-")) {
655 t.Fatal("export not PDF")
656 }
657 return
658 }
659 time.Sleep(25 * time.Millisecond)
660 }
661 t.Fatal("Home export did not become readable PDF")
662}
663
664// Astra's door-copy review: the account-connection start page's action
665// is the verb the page is about, "Connect", not a bare "Continue".
666func TestHomeStartPageActionSaysConnect(t *testing.T) {
667 mux, a, u := homeFixture(t)
668 loc, err := rastrillo.NewLocales(Locales, DefaultLocale, rastrillo.BaseCatalog(), LocaleFS)
669 if err != nil {
670 t.Fatal(err)
671 }
672 r := httptest.NewRequest("GET", homeTestOrigin+"/account/home", nil)
673 r.AddCookie(homeSession(t, a, u.Email, "magiclink"))
674 w := httptest.NewRecorder()
675 loc.Middleware(mux).ServeHTTP(w, r)
676 if w.Code != 200 {
677 t.Fatal(w.Code)
678 }
679 b := w.Body.String()
680 if !strings.Contains(b, "Connect your Oficina account") {
681 t.Fatal("not the connection start page")
682 }
683 if !strings.Contains(b, ">Connect</button>") {
684 t.Error("the start action does not say Connect")
685 }
686 if strings.Contains(b, ">Continue</button>") {
687 t.Error("the start action still says Continue")
688 }
689}
690