package httpd import ( "net/http/httptest" "regexp" "strings" "testing" "time" "gitbay.org/gitbay/internal/config" "gitbay.org/gitbay/internal/store" ) func TestMarkdownMath(t *testing.T) { cases := []struct { name, src string want []string not []string }{ {"inline", "Euler: $e^{i\\pi}+1=0$.", []string{`e`, `.`}, nil}, {"display inline", "so $$x^2$$ here", []string{``}, nil}, {"block", "text\n$$\n\\frac{a}{b}\n$$\nafter\n", []string{``, `

after

`}, []string{"$$"}}, {"one-line block", "$$x_1$$\n", []string{``}, []string{"

"}}, {"prices", "costs $5 and $10 today", []string{"costs $5 and $10 today"}, []string{"$`}, nil}, {"code span", "`$x^2$`", []string{"$x^2$"}, []string{"$$\n\\frac{\n$$"}, []string{"$$\nx

"}, []string{"$$\nx

", "

y $$

"}, []string{"ab`}, nil}, {"markup is escaped", "$\\text{&}$", []string{`<b>&</b>`}, []string{""}}, } for _, c := range cases { out := string(ugcHTML(c.src, "md")) for _, w := range c.want { if !strings.Contains(out, w) { t.Errorf("%s: lacks %q:\n%s", c.name, w, out) } } for _, w := range c.not { if strings.Contains(out, w) { t.Errorf("%s: has %q:\n%s", c.name, w, out) } } } } func TestOrgMath(t *testing.T) { cases := []struct { name, src string want []string not []string }{ {"dollar", "Euler: $e^x$ here", []string{`ex here`}, nil}, {"paren", `a \(x_1\) b`, []string{``}, []string{`\(`}}, {"bracket", `a \[x^2\] b`, []string{``}, []string{`\[`}}, {"double dollar", `a $$x$$ b`, []string{`x`}, nil}, {"environment block", "\\begin{equation}\nx = \\frac{1}{2}\n\\end{equation}\n", []string{`x=`}, []string{`\begin`}}, {"matrix block", "\\begin{pmatrix}\na & b \\\\\nc & d\n\\end{pmatrix}\n", []string{`a`}, nil}, {"prices", "costs $5 and $10 today", []string{"costs $5 and $10 today"}, []string{"$x^2$", "$y$"}, []string{"\begin{tabular}`}, []string{"` + `x` + `(` + `` + `yz` + `` out := mathPolicy.Sanitize(hostile) for _, bad := range []string{"href", "xlink", "style", "onclick", "onmouseover", "onerror", "javascript", "annotation", "semantics", "maction", "mstyle", "mathcolor", "script", "expression", `mathvariant="bold"`, `stretchy="true"`, "form=", `display="inline"`} { if strings.Contains(out, bad) { t.Errorf("sanitized MathML keeps %q:\n%s", bad, out) } } for _, good := range []string{``, ``, "x", "y"} { if !strings.Contains(out, good) { t.Errorf("sanitized MathML lacks %q:\n%s", good, out) } } } // Hostile TeX is refused and shown as escaped source, in bounded time and // size, on both syntaxes. func TestHostileMath(t *testing.T) { long := strings.Repeat(`x+`, 1<<19) // 1 MiB in one expression deep := strings.Repeat("{", 50000) + "x" + strings.Repeat("}", 50000) for _, tex := range []string{ `\href{javascript:alert(1)}{x}`, `\url{javascript:alert(1)}`, `\style{color:red}{x}`, `\color{red" onmouseover="alert(1)}{x}`, `\class{a"b}{x}`, `\def\a{\a\a}\a`, `\text{}`, `\text{}`, deep, long, strings.Repeat(`\sqrt{`, 10000) + "x", } { for _, doc := range []struct{ src, format string }{ {"$" + tex + "$", "md"}, {"$$\n" + tex + "\n$$\n", "md"}, {`\(` + tex + `\)`, "org"}, {"\\[" + tex + "\\]", "org"}, } { start := time.Now() out := string(ugcHTML(doc.src, doc.format)) if d := time.Since(start); d > 2*time.Second { t.Errorf("%.30q (%s) took %v", tex, doc.format, d) } if len(out) > 8*len(doc.src)+1024 { t.Errorf("%.30q (%s): %d bytes out for %d in", tex, doc.format, len(out), len(doc.src)) } for _, bad := range []string{" alone. func TestIssuePageRendersMath(t *testing.T) { st, err := store.Open(":memory:") if err != nil { t.Fatal(err) } defer st.Close() if err := st.MigrateUp(); err != nil { t.Fatal(err) } uid, err := st.CreateUser("alice", false) if err != nil { t.Fatal(err) } repoID, err := st.CreateRepo("user", uid, "app", "public") if err != nil { t.Fatal(err) } if _, err := st.CreateIssue(repoID, uid, "math", `Area is $\pi r^2$, see $\text{#1}$.`, "md"); err != nil { t.Fatal(err) } cfg := config.Default() cfg.Web.Mode = "accounts" s := New(cfg, st, nil) rr := httptest.NewRecorder() s.Handler().ServeHTTP(rr, httptest.NewRequest("GET", "/alice/app/issues/1", nil)) if rr.Code != 200 { t.Fatalf("status %d", rr.Code) } body := rr.Body.String() if !strings.Contains(body, `πr2`) { t.Errorf("no MathML in the issue page:\n%s", body) } if !strings.Contains(body, `#1`) { t.Errorf("autolink rewrote text inside :\n%s", body) } } // MathML written by hand is user HTML, and ugcPolicy strips it: only the // converter's output, through mathPolicy, reaches the page. func TestRawMathMLStripped(t *testing.T) { raw := `q` for name, out := range map[string]string{ "html readme": string(renderReadme("README.html", []byte(raw))), "markdown html": string(renderReadme("README.md", []byte("para "+raw+"\n\n"+raw+"\n"))), "org export": string(renderReadme("README.org", []byte("#+begin_export html\n"+raw+"\n#+end_export\n"))), "org inline": string(renderReadme("README.org", []byte("@@html:"+raw+"@@\n"))), } { if strings.Contains(out, "") { t.Errorf("%s keeps raw MathML:\n%s", name, out) } } for name, out := range map[string]string{ "markdown": string(renderReadme("README.md", []byte("$q$\n"))), "org": string(renderReadme("README.org", []byte("$q$\n"))), } { if !strings.Contains(out, "q") { t.Errorf("%s: no MathML:\n%s", name, out) } } } // Org placeholders: a document cannot spell one, and one that lands in an // attribute is filled with escaped source, not markup. func TestMathSlots(t *testing.T) { out := string(ugcHTML("gitbaymath0z $x$ gitbaymath00000000000000000000000000n0z", "org")) if strings.Count(out, "") != 1 || !strings.Contains(out, "gitbaymath0z") { t.Errorf("forged placeholder: %s", out) } m := newMathSlots() a := m.put(`x`, `$x" onmouseover="y$`) b := m.put(`y`, `$y$`) got := m.fill(`` + b + ``) want := `y` if got != want { t.Errorf("fill:\n got %s\nwant %s", got, want) } if other := newMathSlots(); other.prefix == m.prefix { t.Error("placeholder prefix repeats across renders") } } // Many openers without closers on one line scan in linear time, and the // per-document budget leaves later math as text. func TestMathLinear(t *testing.T) { for _, src := range []string{ strings.Repeat("$a ", 1<<20/3), strings.Repeat("$$a ", 1<<20/4), strings.Repeat("$$\na\n", 1<<20/5), } { for _, format := range []string{"md", "org"} { start := time.Now() ugcHTML(src, format) if d := time.Since(start); d > 2*time.Second { t.Errorf("%s: %.12q x %d took %v", format, src[:4], len(src), d) } } } src := strings.Repeat("$x$ ", maxMathExprs+10) out := string(ugcHTML(src, "md")) if n := strings.Count(out, ""); n != maxMathExprs { t.Errorf("markdown rendered %d expressions, budget %d", n, maxMathExprs) } out = string(ugcHTML(src, "org")) if n := strings.Count(out, ""); n != maxMathExprs { t.Errorf("org rendered %d expressions, budget %d", n, maxMathExprs) } if regexp.MustCompile(`gitbaymath[0-9a-f]+n`).MatchString(out) { t.Error("a placeholder survived") } }