| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/base64" |
| 6 | "encoding/json" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "regexp" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | ) |
| 13 | |
| 14 | // nativeApprovalPage starts a native sign-in on h and opens its approval |
| 15 | // page as c, the signed-in browser, returning the response. |
| 16 | func nativeApprovalPage(t *testing.T, h http.Handler, c *client) *httptest.ResponseRecorder { |
| 17 | t.Helper() |
| 18 | sum := sha256.Sum256([]byte(strings.Repeat("v", 43))) |
| 19 | req := httptest.NewRequest(http.MethodPost, testOrigin+"/native/request", |
| 20 | strings.NewReader(`{"challenge":"`+base64.RawURLEncoding.EncodeToString(sum[:])+`"}`)) |
| 21 | req.Header.Set("Content-Type", "application/json") |
| 22 | req.Header.Set("Origin", testOrigin) |
| 23 | rec := httptest.NewRecorder() |
| 24 | h.ServeHTTP(rec, req) |
| 25 | var out struct{ ID string } |
| 26 | if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil || out.ID == "" { |
| 27 | t.Fatalf("POST /native/request = %d %q", rec.Code, rec.Body.String()) |
| 28 | } |
| 29 | page := c.get(testOrigin + "/native/authorize?id=" + out.ID) |
| 30 | if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "Sign in to Memoria app") { |
| 31 | t.Fatalf("GET /native/authorize = %d, not the approval page:\n%s", page.Code, page.Body.String()) |
| 32 | } |
| 33 | return page |
| 34 | } |
| 35 | |
| 36 | var styleElement = regexp.MustCompile(`(?s)<style>(.*?)</style>`) |
| 37 | |
| 38 | // Astra on #27, P2: the native kit's approval page (/native/authorize) is |
| 39 | // styled by one inline <style> element, and rastrillo fb806a2's strict |
| 40 | // default refused it, so the page lost its card, spacing and button. The |
| 41 | // fix allows that stylesheet by its hash, on that route only. |
| 42 | // |
| 43 | // Read through the production stack: the served stylesheet's own hash |
| 44 | // must be in the route's style-src (so a native bump that changes the CSS |
| 45 | // fails here, not on a phone), and with that one source taken out the |
| 46 | // policy is rastrillo's default exactly, so nothing else is relaxed. No |
| 47 | // other page carries the hash. |
| 48 | // |
| 49 | // Fails on 651f370 (no hash on the route). MUTATIONS (author-chosen, |
| 50 | // 2026-10-06): dropping the wrapper from the GET route; 'unsafe-inline' |
| 51 | // in place of the hash; the wrapper on every route. Each fails here. |
| 52 | // Reverted. |
| 53 | func TestNativeApprovalStyleIsTheHashedOne(t *testing.T) { |
| 54 | c, h := signedInServedClient(t) |
| 55 | page := nativeApprovalPage(t, h, c) |
| 56 | m := styleElement.FindStringSubmatch(page.Body.String()) |
| 57 | if m == nil { |
| 58 | t.Fatal("the approval page has no <style> element; if native/server moved its CSS out, drop the hash (memoria native.go)") |
| 59 | } |
| 60 | sum := sha256.Sum256([]byte(m[1])) |
| 61 | source := "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'" |
| 62 | |
| 63 | policies := page.Header().Values("Content-Security-Policy") |
| 64 | if len(policies) != 1 { |
| 65 | t.Fatalf("CSP values = %q, want one", policies) |
| 66 | } |
| 67 | if !strings.Contains(policies[0], "style-src ") || !strings.Contains(policies[0], " "+source) { |
| 68 | t.Fatalf("the approval page's CSP %q does not admit its own stylesheet %s", policies[0], source) |
| 69 | } |
| 70 | if strings.Contains(policies[0], "'unsafe-inline'") { |
| 71 | t.Errorf("the approval page allows every inline style: %q", policies[0]) |
| 72 | } |
| 73 | if got, want := strings.Replace(policies[0], " "+source, "", 1), defaultCSP(t); got != want { |
| 74 | t.Errorf("with the native hash taken out, the policy is %q, want rastrillo's default %q", got, want) |
| 75 | } |
| 76 | |
| 77 | for _, path := range []string{"/", "/signin", "/auth/verify?token=x"} { |
| 78 | if got := strings.Join(c.get(testOrigin+path).Header().Values("Content-Security-Policy"), "\n"); strings.Contains(got, source) { |
| 79 | t.Errorf("%s carries the native approval page's hash: %q", path, got) |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | // signedInServedClient is a browser signed in through the real magic link |
| 85 | // on servedApp, the production stack. |
| 86 | func signedInServedClient(t *testing.T) (*client, http.Handler) { |
| 87 | t.Helper() |
| 88 | h, mailer := servedApp(t, tempDB(t), []string{chromeOwner}) |
| 89 | c := newClient(t, h) |
| 90 | if rec := c.postSameOrigin(testOrigin+"/auth/begin", map[string][]string{"address": {chromeOwner}}); rec.Code != http.StatusSeeOther { |
| 91 | t.Fatalf("POST /auth/begin = %d", rec.Code) |
| 92 | } |
| 93 | link := regexp.MustCompile(`/auth/verify\?[^\s<>"]+`).FindString(mailer.text()) |
| 94 | if link == "" { |
| 95 | t.Fatalf("no magic link: %q", mailer.text()) |
| 96 | } |
| 97 | if rec := c.openLink(testOrigin + link); rec.Code != http.StatusSeeOther { |
| 98 | t.Fatalf("redeem = %d", rec.Code) |
| 99 | } |
| 100 | return c, h |
| 101 | } |
| 102 | |