| 1 | package memoria |
| 2 | |
| 3 | import ( |
| 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. |
| 25 | func 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". |
| 45 | func 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. |
| 77 | func 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. |
| 103 | func 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. |
| 138 | func 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. |
| 151 | func 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. |
| 170 | func 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. |
| 201 | func 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 "/". |
| 224 | func 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. |
| 268 | func 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". |
| 310 | func 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. |
| 333 | func 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 | |