oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1package memoria
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/hex"
8 "encoding/json"
9 "html"
10 "io"
11 "log/slog"
12 "net/http"
13 "net/http/httptest"
14 "net/url"
15 "regexp"
16 "strings"
17 "testing"
18 "time"
19
20 assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
21)
22
23// §6: the switcher is "All apps" alone (Paul's §13 answer 2), to the hub
24// directory, and only when Home names one. No directory, no switcher.
25func TestTheSwitcherIsAllAppsOnly(t *testing.T) {
26 f := newHomeRouterFixtureDir(t, "https://directory.example.test")
27 f.local(t)
28 b := f.do("GET", "/", "").Body.String()
29 if !strings.Contains(b, "<details estilo-switcher") || !strings.Contains(b, `href="https://directory.example.test/"`) || !strings.Contains(b, ">All apps<") {
30 t.Errorf("the bar has no All apps switcher to the directory:\n%s", b)
31 }
32 if strings.Contains(b, "estilo-switcher-app") {
33 t.Error("the switcher shows app tiles; R2 shows All apps alone")
34 }
35 g := newHomeRouterFixture(t)
36 g.local(t)
37 if strings.Contains(g.do("GET", "/", "").Body.String(), "estilo-switcher") {
38 t.Error("a switcher with no directory configured")
39 }
40}
41
42// §12.1 item 4: "Connect your Oficina account" is offered to a local
43// session when Home is configured, never to a Home session (whose account
44// already is the connection), and never says "Home".
45func TestConnectYourOficinaAccountIsOnlyForLocalSessions(t *testing.T) {
46 f := newHomeRouterFixture(t)
47 f.local(t)
48 b := f.do("GET", "/", "").Body.String()
49 if !strings.Contains(b, `href="/account/home"`) || !strings.Contains(b, "Connect your Oficina account") {
50 t.Error("a local session with Home configured is not offered the connection")
51 }
52 if strings.Contains(b, "Connect Home account") {
53 t.Error("the menu still says Connect Home account")
54 }
55 if w := f.do("GET", "/account/home", ""); w.Code != 200 {
56 t.Fatal("link page", w.Code)
57 }
58 start := homeRedirect(t, f.do("POST", "/account/home", ""))
59 homeRedirect(t, f.exchange(t, start, "opaque-home-alice"))
60 f.do("GET", "/account/home/confirm", "")
61 homeRedirect(t, f.do("POST", "/account/home/confirm", ""))
62 f.cookies = map[string]*http.Cookie{}
63 start = homeRedirect(t, f.do("GET", "/signin", ""))
64 homeRedirect(t, f.exchange(t, start, "opaque-home-alice"))
65 b = f.do("GET", "/", "").Body.String()
66 if !strings.Contains(b, "estilo-account") {
67 t.Fatalf("the Home session is not signed in:\n%s", b)
68 }
69 if strings.Contains(b, `href="/account/home"`) {
70 t.Error("a Home session is offered to connect the account it signed in with")
71 }
72}
73
74// The directory origin is read from HOME_DIRECTORY_ORIGIN, the name Home
75// itself reads, validated with the rest of the Home credentials, and means
76// nothing without them.
77func TestHomeDirectoryOriginIsConfigured(t *testing.T) {
78 env := homeEnv(t)
79 env["HOME_DIRECTORY_ORIGIN"] = "https://directory.example.test"
80 cfg, err := LoadConfig(func(k string) string { return env[k] })
81 if err != nil || cfg.Home == nil || cfg.Home.Directory != "https://directory.example.test" {
82 t.Fatalf("directory not read: %v %+v", err, cfg.Home)
83 }
84 env["HOME_DIRECTORY_ORIGIN"] = "https://directory.example.test/path"
85 if _, err := LoadConfig(func(k string) string { return env[k] }); err == nil {
86 t.Error("a directory origin with a path was accepted")
87 }
88 solo := map[string]string{}
89 for k, v := range env {
90 if !strings.HasPrefix(k, "MEMORIA_HOME_") {
91 solo[k] = v
92 }
93 }
94 solo["HOME_DIRECTORY_ORIGIN"] = "https://directory.example.test"
95 cfg, err = LoadConfig(func(k string) string { return solo[k] })
96 if err != nil || cfg.Home != nil {
97 t.Errorf("a directory without Home configured Home: %v", err)
98 }
99}
100
101// §3.3: a panic inside the router is the 500 page with a reference, and
102// the viewer's own chrome — the recoverer reads the session itself.
103func TestAPanicIsTheErrorPageWithTheViewersChrome(t *testing.T) {
104 f := newHomeRouterFixture(t)
105 f.local(t)
106 boom := f.app.recoverToErrorPage(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("boom") }))
107 for _, signed := range []bool{true, false} {
108 r := httptest.NewRequest("GET", homeTestOrigin+"/boom", nil)
109 r.Header.Set("Accept", "text/html")
110 if signed {
111 for _, c := range f.cookies {
112 r.AddCookie(c)
113 }
114 }
115 w := httptest.NewRecorder()
116 boom.ServeHTTP(w, r)
117 b := w.Body.String()
118 if w.Code != http.StatusInternalServerError || !strings.Contains(b, "<title>Something went wrong · Memoria</title>") || !strings.Contains(b, "rst-error-ref") {
119 t.Errorf("signed in=%v: the panic page = %d:\n%s", signed, w.Code, b)
120 }
121 if strings.Contains(b, "estilo-account") != signed {
122 t.Errorf("signed in=%v: the panic page's account menu is wrong", signed)
123 }
124 if !strings.Contains(b, `href="/boom">Try again`) {
125 t.Errorf("signed in=%v: no Try again to the same URL", signed)
126 }
127 }
128 r := httptest.NewRequest("GET", homeTestOrigin+"/boom", nil)
129 w := httptest.NewRecorder()
130 boom.ServeHTTP(w, r)
131 if w.Code != http.StatusInternalServerError || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/plain") {
132 t.Errorf("a panic answering a non-browser = %d %q, want plain text", w.Code, w.Header().Get("Content-Type"))
133 }
134}
135
136// A panic's value is never logged — it could carry a passage (gate 4) —
137// only its type, beside the reference the page shows.
138func TestAPanicLogsNoContent(t *testing.T) {
139 var buf bytes.Buffer
140 f := newHomeRouterFixture(t)
141 f.app.logger = slog.New(slog.NewTextHandler(&buf, nil))
142 boom := f.app.recoverToErrorPage(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("the secret passage") }))
143 boom.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", homeTestOrigin+"/boom", nil))
144 if strings.Contains(buf.String(), "secret passage") || !strings.Contains(buf.String(), "ref=") {
145 t.Errorf("the panic log line: %s", buf.String())
146 }
147 _ = io.Discard
148}
149
150// §4: the import's sentence is a callout toned by the run's state.
151func TestTheImportMessageIsToned(t *testing.T) {
152 for _, c := range []struct {
153 run SyncRun
154 tone string
155 }{
156 {SyncRun{State: RunFetching}, ""},
157 {SyncRun{State: RunDone}, ""},
158 {SyncRun{State: RunDone, FailedPages: 2}, "warning"},
159 {SyncRun{State: RunInterrupted}, "warning"},
160 {SyncRun{State: RunFailed, ErrorCode: "network"}, "warning"},
161 } {
162 if got := runTone(&c.run); got != c.tone {
163 t.Errorf("%s (failed pages %d): tone %q, want %q", c.run.State, c.run.FailedPages, got, c.tone)
164 }
165 }
166}
167
168// §9.4: the return cookie is sealed, bound to this origin, and carries an
169// expiry that is checked on read.
170func TestTheReturnCookieIsSealedAndExpires(t *testing.T) {
171 f := newHomeRouterFixture(t)
172 now := int64(1_800_000_000)
173 f.app.clock = func() time.Time { return time.Unix(now, 0) }
174 w := httptest.NewRecorder()
175 f.app.rememberReturn(w, "/documents/7")
176 c := w.Result().Cookies()[0]
177 raw, _ := base64.RawURLEncoding.DecodeString(c.Value)
178 if strings.Contains(string(raw), "/documents/7") {
179 t.Fatal("the return cookie is readable")
180 }
181 read := func() (string, bool) {
182 r := httptest.NewRequest("GET", homeTestOrigin+"/auth/landed", nil)
183 r.AddCookie(&http.Cookie{Name: c.Name, Value: c.Value})
184 return f.app.rememberedReturn(r)
185 }
186 if p, ok := read(); !ok || p != "/documents/7" {
187 t.Fatalf("the cookie did not read back: %q %v", p, ok)
188 }
189 now += returnCookieAge + 1
190 if _, ok := read(); ok {
191 t.Error("an expired return cookie was honoured")
192 }
193 now -= returnCookieAge + 1
194 f.app.origin = "https://other.example.test"
195 if _, ok := read(); ok {
196 t.Error("a return cookie from another origin was honoured")
197 }
198}
199
200// homeEnv is a complete, valid environment with Home configured.
201func homeEnv(t *testing.T) map[string]string {
202 t.Helper()
203 key, err := assertioncrypto.Generate()
204 if err != nil {
205 t.Fatal(err)
206 }
207 sum := sha256.Sum256(key.SignPub())
208 raw, err := json.Marshal(map[string]string{hex.EncodeToString(sum[:]): base64.StdEncoding.EncodeToString(key.SignPub())})
209 if err != nil {
210 t.Fatal(err)
211 }
212 return map[string]string{
213 "MEMORIA_DEK": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32)), "MEMORIA_INSTANCE_KEY": "test-only-instance",
214 "MEMORIA_OWNERS": "alice@example.test", "MEMORIA_ALLOW_LOGGED_MAIL": "1", "MEMORIA_ORIGIN": homeTestOrigin,
215 "MEMORIA_HOME_ISSUER": homeTestIssuer, "MEMORIA_HOME_CONSUMER_ID": "memoria-test",
216 "MEMORIA_HOME_KEYS_BASE64": base64.StdEncoding.EncodeToString(raw), "MEMORIA_HOME_DEK_BASE64": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)),
217 }
218}
219
220// Astra on #21, finding 1: with Home configured, both ways into the local
221// form — connect's forced /signin?force=1 and /signin/local — keep an
222// explicit return_to through sent → "Use a different address" → a second
223// submission → verification. The forced entry used to land on "/".
224func TestHomeLocalRetryKeepsTheReturnFromEitherEntry(t *testing.T) {
225 for _, entry := range []string{"/signin?force=1&return_to=%2Fadd%3Ffrom%3Dmail", "/signin/local?return_to=%2Fadd%3Ffrom%3Dmail"} {
226 t.Run(entry, func(t *testing.T) {
227 f := newHomeRouterFixture(t)
228 if w := f.do("GET", entry, ""); w.Code != http.StatusOK {
229 t.Fatalf("GET %s = %d", entry, w.Code)
230 }
231 sent := homeRedirect(t, f.do("POST", "/auth/begin", "address=alice%40example.test&force=1"))
232 page := f.do("GET", sent, "").Body.String()
233 m := regexp.MustCompile(`<a href="([^"]+)">Use a different address</a>`).FindStringSubmatch(page)
234 if m == nil {
235 t.Fatalf("no \"Use a different address\" on the sent page:\n%s", page)
236 }
237 again := html.UnescapeString(m[1])
238 if again != "/signin/local?return_to=%2Fadd%3Ffrom%3Dmail" {
239 t.Fatalf("\"Use a different address\" goes to %q, want the local form with the return", again)
240 }
241 if w := f.do("GET", again, ""); w.Code != http.StatusOK {
242 t.Fatalf("GET %s = %d", again, w.Code)
243 }
244 f.mail.body = ""
245 homeRedirect(t, f.do("POST", "/auth/begin", "address=alice%40example.test&force=1"))
246 link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mail.body)
247 u, err := url.Parse(link)
248 if err != nil || link == "" {
249 t.Fatal("no second link")
250 }
251 dest := homeRedirect(t, f.openLink(t, u.RequestURI()))
252 for i := 0; i < 3 && strings.HasPrefix(dest, "/auth/"); i++ {
253 dest = homeRedirect(t, f.do("GET", dest, ""))
254 }
255 if dest != "/add?from=mail" {
256 t.Errorf("sign-in landed on %q, want /add?from=mail", dest)
257 }
258 })
259 }
260}
261
262// Astra on #21, finding 2: the panic tests above build recoverToErrorPage
263// by hand, so removing it from the router left them green. This one panics
264// inside a REAL route (the return cookie's clock, read by /signin/local)
265// through the production router, signed in and out, and checks the generic
266// 500 page with a reference, the viewer's chrome, and that the panic's
267// value reaches neither the page nor the log.
268func TestARoutePanicIsTheErrorPageThroughTheRouter(t *testing.T) {
269 const sentinel = "secret-sentinel-passage"
270 for _, signed := range []bool{true, false} {
271 f := newHomeRouterFixture(t)
272 if signed {
273 f.local(t)
274 }
275 var logs bytes.Buffer
276 f.app.logger = slog.New(slog.NewTextHandler(&logs, nil))
277 f.app.clock = func() time.Time { panic(sentinel) }
278 r := httptest.NewRequest("GET", homeTestOrigin+"/signin/local?return_to=%2Fadd", nil)
279 r.Header.Set("Accept", "text/html")
280 for _, c := range f.cookies {
281 r.AddCookie(c)
282 }
283 w := httptest.NewRecorder()
284 func() {
285 defer func() {
286 if v := recover(); v != nil {
287 t.Fatalf("signed in=%v: the panic escaped the router: %v", signed, v)
288 }
289 }()
290 f.handler.ServeHTTP(w, r)
291 }()
292 b := w.Body.String()
293 if w.Code != http.StatusInternalServerError || !strings.Contains(b, "<title>Something went wrong · Memoria</title>") || !strings.Contains(b, "rst-error-ref") {
294 t.Errorf("signed in=%v: = %d, want the 500 page with a reference:\n%s", signed, w.Code, b)
295 }
296 if strings.Contains(b, "estilo-account") != signed {
297 t.Errorf("signed in=%v: the 500 page's chrome is not the viewer's", signed)
298 }
299 if strings.Contains(b, sentinel) || strings.Contains(logs.String(), sentinel) {
300 t.Errorf("signed in=%v: the panic's value reached the page or the log", signed)
301 }
302 if !strings.Contains(logs.String(), "ref=") {
303 t.Errorf("signed in=%v: no reference in the log: %s", signed, logs.String())
304 }
305 }
306}
307
308// Astra's door-copy review: the account-connection start page's action
309// is the verb the page is about, "Connect", not a bare "Continue".
310func TestConnectStartPageActionSaysConnect(t *testing.T) {
311 f := newHomeRouterFixture(t)
312 f.local(t)
313 w := f.do("GET", "/account/home", "")
314 if w.Code != 200 {
315 t.Fatal(w.Code)
316 }
317 b := w.Body.String()
318 if !strings.Contains(b, "Connect your Oficina account") {
319 t.Fatal("not the connection start page")
320 }
321 if !strings.Contains(b, ">Connect</button>") {
322 t.Error("the start action does not say Connect")
323 }
324 if strings.Contains(b, ">Continue</button>") {
325 t.Error("the start action still says Continue")
326 }
327}
328
329// Standalone, a failed Keymail sign-in lands on /signin?force=1&err=keymail.
330// The form must carry force=1 back, or the next submission classifies the
331// address as Keymail again and loops straight back to Keymail. Only Home
332// mode used to carry it.
333func TestStandaloneKeymailFallbackCarriesForce(t *testing.T) {
334 f := newLocalOnlyFixture(t)
335 w := f.do("GET", "/signin?force=1&err=keymail", "")
336 if w.Code != http.StatusOK {
337 t.Fatalf("GET = %d", w.Code)
338 }
339 if !regexp.MustCompile(`<input[^>]*name="force"[^>]*value="1"`).MatchString(w.Body.String()) {
340 t.Fatalf("the fallback form does not carry force=1:\n%s", w.Body.String())
341 }
342}
343