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 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "regexp"
8 "strings"
9 "testing"
10
11 "amadan.net/oficina/home/internal/home/sealtest"
12)
13
14// The shared chrome (estilo spec shared-chrome, §12.1 row 8, Home): every
15// page Home draws for a person, other than the public front page, sits
16// under estilo's 52px bar with the brand "Oficina". On the directory, a
17// signed-in person has one account menu (who, Passkeys and Recovery codes
18// on id.*, Appearance, Help → Open source, one Sign out); signed out, the
19// corner is "Sign in". id.* pages are the door: the bar carries the brand
20// only, linking back to the directory.
21
22// chromeGet is a GET with an Accept header (empty sends none).
23func chromeGet(h http.Handler, target, accept string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
24 r := httptest.NewRequest(http.MethodGet, target, nil)
25 if accept != "" {
26 r.Header.Set("Accept", accept)
27 }
28 for _, c := range cookies {
29 if c != nil {
30 r.AddCookie(c)
31 }
32 }
33 rec := httptest.NewRecorder()
34 h.ServeHTTP(rec, r)
35 return rec
36}
37
38var pageTitle = regexp.MustCompile(`<title>([^<]*)</title>`)
39
40func titleOf(body string) string {
41 m := pageTitle.FindStringSubmatch(body)
42 if m == nil {
43 return ""
44 }
45 return m[1]
46}
47
48const acceptHTML = "text/html,application/xhtml+xml"
49
50func TestHomePagesWearTheSharedBar(t *testing.T) {
51 f := newMountedChooser(t)
52 directory := f.p.directory.Router()
53 credential := f.p.credential.Router()
54 person := f.session(t, chooserPerson)
55 idSession := signInFully(t, f.p.credential, sealtest.CanaryAddress)
56
57 type want struct {
58 title string
59 account bool // the account menu, and with it exactly one Sign out
60 signIn bool // the signed-out corner's "Sign in"
61 sky bool
62 brand string // the brand link's href
63 }
64 cases := []struct {
65 name string
66 h http.Handler
67 target string
68 cookie *http.Cookie
69 want want
70 }{
71 {"directory", directory, directoryOrigin + "/", person, want{"Oficina", true, false, true, "/"}},
72 {"team chooser", directory, directoryOrigin + docsEntryPath, person, want{"No teams available · Oficina", true, false, false, "/"}},
73 {"open source, signed in", directory, directoryOrigin + "/help/open-source", person, want{"Open source · Oficina", true, false, false, "/"}},
74 {"open source, signed out", directory, directoryOrigin + "/help/open-source", nil, want{"Open source · Oficina", false, true, false, "/"}},
75 {"signed out", directory, directoryOrigin + "/signed-out", nil, want{"Signed out · Oficina", false, false, true, "/"}},
76 {"id.* sign in", credential, Origin + "/signin", nil, want{"Sign in · Oficina", false, false, true, directoryOrigin + "/"}},
77 {"id.* check your email", credential, Origin + "/signin/sent", nil, want{"Check your email · Oficina", false, false, true, directoryOrigin + "/"}},
78 {"id.* recovery codes", credential, Origin + "/passkey/codes", idSession, want{"Recovery codes · Oficina", false, false, true, directoryOrigin + "/"}},
79 {"id.* add a passkey", credential, Origin + "/passkey/enrol", idSession, want{"Add a passkey · Oficina", false, false, true, directoryOrigin + "/"}},
80 {"id.* open source", credential, Origin + "/help/open-source", nil, want{"Open source · Oficina", false, false, false, directoryOrigin + "/"}},
81 {"id.* sign out", credential, Origin + "/signout", nil, want{"Finish signing out · Oficina", false, false, true, directoryOrigin + "/"}},
82 }
83 for _, c := range cases {
84 t.Run(c.name, func(t *testing.T) {
85 rec := chromeGet(c.h, c.target, acceptHTML, c.cookie)
86 body := rec.Body.String()
87 if rec.Code != http.StatusOK {
88 t.Fatalf("status %d", rec.Code)
89 }
90 if got := titleOf(body); got != c.want.title {
91 t.Errorf("title %q, want %q", got, c.want.title)
92 }
93 if strings.Count(body, "<header estilo-bar") != 1 {
94 t.Errorf("want exactly one estilo-bar")
95 }
96 if !strings.Contains(body, `<a estilo-bar-brand href="`+c.want.brand+`">`) || !strings.Contains(body, `<span estilo-bar-name>Oficina</span>`) {
97 t.Errorf("the brand is not \"Oficina\" linking to %s", c.want.brand)
98 }
99 if strings.Contains(body, "rst-dropdown") || strings.Contains(body, "home-hub-head><h1 estilo-brand") {
100 t.Errorf("the old lone Help menu or hub header is still drawn")
101 }
102 if got := strings.Contains(body, "<details estilo-account"); got != c.want.account {
103 t.Errorf("account menu: %v, want %v", got, c.want.account)
104 }
105 signOuts := strings.Count(body, ">Sign out<")
106 if c.want.account {
107 if signOuts != 1 {
108 t.Errorf("%d Sign out buttons, want exactly one", signOuts)
109 }
110 for _, item := range []string{
111 `href="` + Origin + `/passkey/enrol"`,
112 `href="` + Origin + `/passkey/codes"`,
113 `action="/appearance"`,
114 `href="/help/open-source"`,
115 `<form method="post" action="/signout">`,
116 } {
117 if !strings.Contains(body, item) {
118 t.Errorf("the account menu lacks %s", item)
119 }
120 }
121 } else if c.name != "id.* sign out" && signOuts != 0 {
122 t.Errorf("%d Sign out buttons with no account menu", signOuts)
123 }
124 if got := strings.Contains(body, "estilo-bar-signin"); got != c.want.signIn {
125 t.Errorf("Sign in corner: %v, want %v", got, c.want.signIn)
126 }
127 if got := strings.Contains(body, "<div estilo-sky "); got != c.want.sky {
128 t.Errorf("sky: %v, want %v (sign-in, team home header and front page only)", got, c.want.sky)
129 }
130 })
131 }
132}
133
134// The front page keeps its own design: no app bar. Its title is the hub's
135// name alone (spec §10), not "Oficina — open your new office suite".
136func TestFrontPageKeepsItsOwnDesign(t *testing.T) {
137 f := newMountedChooser(t)
138 body := chromeGet(f.p.directory.Router(), directoryOrigin+"/", acceptHTML).Body.String()
139 if strings.Contains(body, "estilo-bar") {
140 t.Error("the public front page has the app bar")
141 }
142 if got := titleOf(body); got != "Oficina" {
143 t.Errorf("front page title %q, want \"Oficina\"", got)
144 }
145}
146
147// Appearance: a signed-in person's choice, kept per host (spec §8.3).
148func TestAppearanceSetsTheTheme(t *testing.T) {
149 f := newMountedChooser(t)
150 person := f.session(t, chooserPerson)
151 rec := f.request("POST", "/appearance", directoryOrigin, url.Values{"mode": {"dark"}, "return_to": {"/"}}.Encode(), person)
152 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
153 t.Fatalf("POST /appearance: %d %q", rec.Code, rec.Header().Get("Location"))
154 }
155 var mode *http.Cookie
156 for _, c := range rec.Result().Cookies() {
157 if c.Name == "estilo_appearance" {
158 mode = c
159 }
160 }
161 if mode == nil || mode.Value != "dark" {
162 t.Fatalf("no estilo_appearance=dark cookie: %v", rec.Result().Cookies())
163 }
164 body := chromeGet(f.p.directory.Router(), directoryOrigin+"/", acceptHTML, person, mode).Body.String()
165 if !strings.Contains(body, `<html lang="en" data-theme="dark">`) {
166 t.Errorf("the directory does not draw the chosen theme: %.120s", body)
167 }
168 if !strings.Contains(body, `value="dark" aria-pressed="true"`) {
169 t.Error("Dark is not shown as the current appearance")
170 }
171
172 // return_to is the attacker's half of the form: anything that would
173 // leave the directory goes to "/" instead. Raw form bodies, as a
174 // hostile page would post them: %09 and %5c decode to a tab and a
175 // backslash, which browsers strip or fold into "/" before navigating.
176 for _, hostile := range []string{"//evil", "/%09/evil", "/%5c/evil", "/%5C/evil", "https://evil", "https:%2F%2Fevil", "/%0d%0a/evil"} {
177 rec := f.request("POST", "/appearance", directoryOrigin, "mode=dark&return_to="+hostile, person)
178 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
179 t.Errorf("return_to=%s: %d to %q, want 303 to /", hostile, rec.Code, rec.Header().Get("Location"))
180 }
181 }
182}
183
184// Appearance comes back to the page it was chosen on only if that page
185// is there to come back to: a GET that drew a 200. A 404's own path, an
186// error page's, or the path of a POST that drew a page would 303 back to
187// a page that is not there (a POST-only path answers a GET with a 404).
188func TestAppearanceReturnsOnlyToARealPage(t *testing.T) {
189 f := newMountedChooser(t)
190 person := f.session(t, chooserPerson)
191 directory := f.p.directory.Router()
192 returnTo := regexp.MustCompile(`name="return_to" value="([^"]*)"`)
193 for _, c := range []struct {
194 name, target, want string
195 }{
196 {"the directory", "/", "/"},
197 {"open source", "/help/open-source", "/help/open-source"},
198 {"a 404", "/no/such/page", "/"},
199 {"a GET to a POST-only path", "/appearance", "/"},
200 } {
201 t.Run(c.name, func(t *testing.T) {
202 body := chromeGet(directory, directoryOrigin+c.target, acceptHTML, person).Body.String()
203 m := returnTo.FindStringSubmatch(body)
204 if m == nil {
205 t.Fatalf("no Appearance return_to on %s", c.target)
206 }
207 if m[1] != c.want {
208 t.Errorf("return_to %q, want %q", m[1], c.want)
209 }
210 })
211 }
212 // A page drawn in answer to a POST, or with any status but 200.
213 for _, c := range []struct {
214 method string
215 status int
216 }{
217 {http.MethodPost, http.StatusOK},
218 {http.MethodGet, http.StatusServiceUnavailable},
219 {http.MethodGet, http.StatusInternalServerError},
220 } {
221 r := httptest.NewRequest(c.method, directoryOrigin+"/help/open-source", nil)
222 r.Header.Set("Accept", acceptHTML)
223 r.AddCookie(person)
224 var got string
225 f.p.directory.sessions.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
226 got = f.p.directory.account(r, c.status).Appearance.Return
227 })).ServeHTTP(httptest.NewRecorder(), r)
228 if got != "/" {
229 t.Errorf("%s drawing a %d: return_to %q, want \"/\"", c.method, c.status, got)
230 }
231 }
232}
233
234// A 500 for a GET offers to try the page again beside the way home; it
235// does not replace the way home, which is the one link known to work.
236func TestServerErrorKeepsTheWayHome(t *testing.T) {
237 f := newMountedChooser(t)
238 for _, c := range []struct {
239 name string
240 app *App
241 method string
242 target string
243 home string
244 tryAgain bool
245 }{
246 {"directory, GET", f.p.directory, http.MethodGet, directoryOrigin + "/help/open-source?x=1", "/", true},
247 {"directory, POST", f.p.directory, http.MethodPost, directoryOrigin + "/appearance", "/", false},
248 {"id.*, GET", f.p.credential.App, http.MethodGet, Origin + "/passkey/codes", directoryOrigin + "/", true},
249 } {
250 t.Run(c.name, func(t *testing.T) {
251 r := httptest.NewRequest(c.method, c.target, nil)
252 r.Header.Set("Accept", acceptHTML)
253 rec := httptest.NewRecorder()
254 c.app.serverError(rec, r)
255 body := rec.Body.String()
256 if rec.Code != http.StatusInternalServerError {
257 t.Fatalf("status %d", rec.Code)
258 }
259 if !strings.Contains(body, `<a rst-btn="primary" href="`+c.home+`">Go to Oficina</a>`) {
260 t.Errorf("no Go to Oficina link to %s: %s", c.home, errorCTA(body))
261 }
262 again := `<a rst-btn href="` + strings.TrimPrefix(strings.TrimPrefix(c.target, directoryOrigin), Origin) + `">Try again</a>`
263 if got := strings.Contains(body, ">Try again</a>"); got != c.tryAgain {
264 t.Errorf("Try again shown %v, want %v: %s", got, c.tryAgain, errorCTA(body))
265 }
266 if c.tryAgain && !strings.Contains(body, again) {
267 t.Errorf("Try again does not retry the page (want %s): %s", again, errorCTA(body))
268 }
269 })
270 }
271}
272
273// errorCTA is the error page's links, for a failure message.
274func errorCTA(body string) string {
275 if i := strings.Index(body, "<p rst-error-cta>"); i >= 0 {
276 end := strings.Index(body[i:], "</p>")
277 if end > 0 {
278 return body[i : i+end]
279 }
280 }
281 return "(no rst-error-cta)"
282}
283
284// Every status the error page can be asked for has its own words; one
285// nobody wrote words for gets a general page, never a blank heading.
286func TestErrorPageHasCopyForEveryStatus(t *testing.T) {
287 f := newMountedChooser(t)
288 headings := map[string]int{}
289 for _, status := range []int{
290 http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
291 http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusRequestEntityTooLarge,
292 http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusServiceUnavailable,
293 } {
294 heading := checkErrorCopy(t, f.p.directory, status)
295 if other, ok := headings[heading]; ok {
296 t.Errorf("%d and %d share the heading %q", status, other, heading)
297 }
298 headings[heading] = status
299 }
300 // Statuses with no words of their own: a general page.
301 for _, status := range []int{http.StatusTeapot, http.StatusConflict, http.StatusBadGateway} {
302 checkErrorCopy(t, f.p.directory, status)
303 }
304}
305
306var (
307 errorHeading = regexp.MustCompile(`<h1 rst-error-title>([^<]*)</h1>`)
308 errorBody = regexp.MustCompile(`<p rst-error-body>([^<]*)</p>`)
309)
310
311// checkErrorCopy draws status's error page and fails on any part of its
312// copy left blank. It returns the heading.
313func checkErrorCopy(t *testing.T, a *App, status int) string {
314 t.Helper()
315 r := httptest.NewRequest(http.MethodGet, directoryOrigin+"/x", nil)
316 r.Header.Set("Accept", acceptHTML)
317 rec := httptest.NewRecorder()
318 a.errorPage(rec, r, status)
319 body := rec.Body.String()
320 if rec.Code != status {
321 t.Errorf("%d: drew a %d", status, rec.Code)
322 }
323 title := titleOf(body)
324 h := errorHeading.FindStringSubmatch(body)
325 b := errorBody.FindStringSubmatch(body)
326 if title == "" || strings.HasPrefix(title, " ·") || strings.HasPrefix(title, "·") {
327 t.Errorf("%d: tab title %q", status, title)
328 }
329 if h == nil || strings.TrimSpace(h[1]) == "" {
330 t.Errorf("%d: no heading", status)
331 return ""
332 }
333 if b == nil || strings.TrimSpace(b[1]) == "" {
334 t.Errorf("%d: no body copy", status)
335 }
336 return h[1]
337}
338
339// estilo's error page on both origins, for a browser; everything else
340// keeps a plain-text answer (spec §3.3). The 404 used to be chi's bare
341// "404 page not found" whatever was asking.
342func TestErrorPagesSplitByAccept(t *testing.T) {
343 f := newMountedChooser(t)
344 person := f.session(t, chooserPerson)
345 cases := []struct {
346 name string
347 h http.Handler
348 target string
349 cookie *http.Cookie
350 account bool
351 }{
352 {"directory, signed in", f.p.directory.Router(), directoryOrigin + "/no/such/page", person, true},
353 {"directory, signed out", f.p.directory.Router(), directoryOrigin + "/no/such/page", nil, false},
354 {"directory, GET to a POST-only path", f.p.directory.Router(), directoryOrigin + "/appearance", person, true},
355 {"id.*", f.p.credential.Router(), Origin + "/no/such/page", nil, false},
356 {"id.*, GET to a POST-only path", f.p.credential.Router(), Origin + "/passkey/signin/begin", nil, false},
357 }
358 for _, c := range cases {
359 t.Run(c.name, func(t *testing.T) {
360 rec := chromeGet(c.h, c.target, acceptHTML, c.cookie)
361 body := rec.Body.String()
362 if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
363 t.Fatalf("browser: %d %q, want a 404 page", rec.Code, rec.Header().Get("Content-Type"))
364 }
365 if titleOf(body) != "Page not found · Oficina" || !strings.Contains(body, "<div rst-error>") || !strings.Contains(body, "We can’t find that page") || !strings.Contains(body, "<header estilo-bar") {
366 t.Errorf("not estilo's error page under the bar: %q", titleOf(body))
367 }
368 if got := strings.Contains(body, "<details estilo-account"); got != c.account {
369 t.Errorf("account menu %v, want %v", got, c.account)
370 }
371 if rec.Header().Get("Cache-Control") != "no-store" {
372 t.Errorf("Cache-Control %q", rec.Header().Get("Cache-Control"))
373 }
374 for _, accept := range []string{"", "*/*", "application/json"} {
375 rec := chromeGet(c.h, c.target, accept, c.cookie)
376 if (rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed) || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") {
377 t.Errorf("Accept %q: %d %q, want a plain 404 or 405", accept, rec.Code, rec.Header().Get("Content-Type"))
378 }
379 }
380 })
381 }
382 // An unknown host is not a page anyone navigates to: still empty.
383 rec := chromeGet(f.p.directory.Router(), "https://elsewhere.example/no/such/page", acceptHTML)
384 if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 {
385 t.Errorf("unknown host: %d %q", rec.Code, rec.Body.String())
386 }
387}
388
389// "Use a different address" and "Start again" keep where you were going
390// (r3 C1). Both were a bare /signin, which forgets return_to: the magic
391// link then ended at id.* "/", which sends you to the signed-out front
392// page, and a sign-in from an app lost the app.
393func TestStartingAgainKeepsTheDestination(t *testing.T) {
394 app := newTestApp(t)
395 h := app.Router()
396 form := chromeGet(h, Origin+"/signin?return_to=%2Fauthorize%2Fresume", acceptHTML)
397 var keep *http.Cookie
398 for _, c := range form.Result().Cookies() {
399 if c.Name == returnCookie && c.Value != "" {
400 keep = c
401 }
402 }
403 if keep == nil {
404 t.Fatal("the form did not remember return_to")
405 }
406 const again = `href="/signin?return_to=%2Fauthorize%2Fresume"`
407 sent := chromeGet(h, Origin+"/signin/sent", acceptHTML, keep).Body.String()
408 if !strings.Contains(sent, again+">Use a different address") {
409 t.Errorf("Use a different address forgets the destination:\n%s", sent)
410 }
411 r := httptest.NewRequest(http.MethodPost, Origin+linkPath, strings.NewReader("token=nope"))
412 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
413 r.Header.Set("Origin", Origin)
414 r.AddCookie(keep)
415 rec := httptest.NewRecorder()
416 h.ServeHTTP(rec, r)
417 if !strings.Contains(rec.Body.String(), again+">Start again") {
418 t.Errorf("Start again forgets the destination:\n%s", rec.Body.String())
419 }
420 // With nowhere remembered, both stay a plain /signin.
421 if body := chromeGet(h, Origin+"/signin/sent", acceptHTML).Body.String(); !strings.Contains(body, `href="/signin">Use a different address`) {
422 t.Errorf("with no destination: %s", body)
423 }
424}
425
426// The hand-off hop keeps the sky's inline placeholder (r3 C2): it used to
427// draw neither sky nor card, so every sign-in flashed a blank page between
428// two skies. Only the placeholder: it is a data: URI, so the hop still
429// fetches nothing it will not show.
430func TestHopKeepsTheSkyPlaceholder(t *testing.T) {
431 p := newExchangePair(t)
432 credential := signInFully(t, p.credential, sealtest.CanaryAddress)
433 _, intent := p.start(t)
434 rec := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, credential)
435 body := rec.Body.String()
436 if rec.Code != http.StatusOK || !strings.Contains(body, `name="assertion"`) {
437 t.Fatalf("resume: %d", rec.Code)
438 }
439 if !strings.Contains(body, `estilo-sky-layer="placeholder" estilo-sky-tone="light" src="data:`) {
440 t.Error("the hop draws no sky")
441 }
442 if strings.Contains(body, "grain.svg") || strings.Contains(body, "filaments.svg") || strings.Contains(body, "busy.js") {
443 t.Error("the hop fetches sky layers or scripts it will not show")
444 }
445}
446
447// A cold first load paints the mark and Onest at once (r3 C4): the font
448// and the brand pictures are preloaded, on the front page and every page
449// in the layout.
450func TestColdLoadPreloadsTheFontAndMarks(t *testing.T) {
451 f := newMountedChooser(t)
452 font := `<link rel="preload" href="/static/estilo/fonts/v1/onest-latin.woff2" as="font" type="font/woff2" crossorigin>`
453 front := chromeGet(f.p.directory.Router(), directoryOrigin+"/", acceptHTML).Body.String()
454 signin := chromeGet(f.p.credential.Router(), Origin+"/signin", acceptHTML).Body.String()
455 for name, body := range map[string]string{"front page": front, "id.* sign in": signin} {
456 if !strings.Contains(body, font) {
457 t.Errorf("%s: Onest is not preloaded", name)
458 }
459 if !strings.Contains(body, `<link rel="preload" href="/static/brand/icon.svg?v=`) {
460 t.Errorf("%s: the Oficina mark is not preloaded", name)
461 }
462 }
463 for _, app := range []string{"docs", "sheets", "slides", "calendar", "calls", "memoria"} {
464 if !strings.Contains(front, `<link rel="preload" href="/static/brand/`+app+`.svg?v=`) {
465 t.Errorf("front page: %s's mark is not preloaded", app)
466 }
467 }
468}
469