| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | require "rails_helper" |
| 4 | |
| 5 | RSpec.describe MarkdownRenderer do |
| 6 | def render(md, context: nil) |
| 7 | described_class.render(md, context: context) |
| 8 | end |
| 9 | |
| 10 | describe "sanitization (the security boundary)" do |
| 11 | it "strips <script> tags and their contents" do |
| 12 | html = render("Hello\n\n<script>alert('xss')</script>") |
| 13 | expect(html).not_to include("<script") |
| 14 | expect(html).not_to include("alert('xss')") |
| 15 | end |
| 16 | |
| 17 | it "strips <iframe> elements" do |
| 18 | expect(render("<iframe src='https://evil.example'></iframe>")).not_to include("<iframe") |
| 19 | end |
| 20 | |
| 21 | it "strips inline event handlers" do |
| 22 | html = render("<img src='x' onerror='alert(1)'>") |
| 23 | expect(html).not_to include("onerror") |
| 24 | expect(html).not_to include("alert(1)") |
| 25 | end |
| 26 | |
| 27 | it "strips javascript: scheme links" do |
| 28 | expect(render("[click](javascript:alert(1))")).not_to include("javascript:") |
| 29 | end |
| 30 | |
| 31 | it "keeps safe http(s) links" do |
| 32 | expect(render("[siGit](https://sigit.si)")).to include('href="https://sigit.si"') |
| 33 | end |
| 34 | end |
| 35 | |
| 36 | describe "GitHub-Flavored Markdown" do |
| 37 | it "renders tables" do |
| 38 | html = render("| a | b |\n|---|---|\n| 1 | 2 |") |
| 39 | expect(html).to include("<table").and include("<th").and include("<td") |
| 40 | end |
| 41 | |
| 42 | it "renders task lists as disabled checkboxes" do |
| 43 | html = render("- [ ] todo\n- [x] done") |
| 44 | expect(html.scan(/<input[^>]*type="checkbox"/).length).to eq(2) |
| 45 | expect(html).to include("checked") |
| 46 | expect(html).to include("task-list-item") |
| 47 | end |
| 48 | |
| 49 | it "gives headings anchor ids and links" do |
| 50 | html = render("# Hello World") |
| 51 | expect(html).to include('id="hello-world"').and include('href="#hello-world"') |
| 52 | end |
| 53 | |
| 54 | it "makes duplicate heading ids unique" do |
| 55 | html = render("# Title\n\n# Title") |
| 56 | expect(html).to include('id="title"').and include('id="title-1"') |
| 57 | end |
| 58 | |
| 59 | it "syntax-highlights fenced code" do |
| 60 | html = render("```ruby\nputs 1\n```") |
| 61 | expect(html).to include('class="highlight code-block"').and include("<span") |
| 62 | end |
| 63 | |
| 64 | it "autolinks bare URLs" do |
| 65 | expect(render("see https://sigit.si for more")).to include('href="https://sigit.si"') |
| 66 | end |
| 67 | end |
| 68 | |
| 69 | describe "relative link/image resolution" do |
| 70 | let(:ctx) { MarkdownRenderer::Context.new(username: "sigit", repository: "demo", ref: "main", dir: dir) } |
| 71 | let(:dir) { "" } |
| 72 | |
| 73 | it "resolves a relative link to the blob view" do |
| 74 | expect(render("[docs](docs/guide.md)", context: ctx)) |
| 75 | .to include('href="/sigit/demo/blob/main/docs/guide.md"') |
| 76 | end |
| 77 | |
| 78 | it "resolves a relative image to the raw endpoint" do |
| 79 | expect(render("", context: ctx)) |
| 80 | .to include('src="/sigit/demo/raw/main/assets/logo.png"') |
| 81 | end |
| 82 | |
| 83 | context "from a nested directory" do |
| 84 | let(:dir) { "docs" } |
| 85 | |
| 86 | it "resolves against the current directory" do |
| 87 | expect(render("[sibling](other.md)", context: ctx)) |
| 88 | .to include('href="/sigit/demo/blob/main/docs/other.md"') |
| 89 | end |
| 90 | end |
| 91 | |
| 92 | context "with parent traversal" do |
| 93 | let(:dir) { "docs/api" } |
| 94 | |
| 95 | it "collapses .. against the current directory" do |
| 96 | expect(render("[up](../intro.md)", context: ctx)) |
| 97 | .to include('href="/sigit/demo/blob/main/docs/intro.md"') |
| 98 | end |
| 99 | end |
| 100 | |
| 101 | it "leaves absolute URLs untouched" do |
| 102 | expect(render("[x](https://example.com/a)", context: ctx)).to include('href="https://example.com/a"') |
| 103 | end |
| 104 | |
| 105 | it "leaves in-page anchors untouched" do |
| 106 | expect(render("[top](#intro)", context: ctx)).to include('href="#intro"') |
| 107 | end |
| 108 | end |
| 109 | |
| 110 | it "returns an empty string for blank input" do |
| 111 | expect(render("")).to eq("") |
| 112 | expect(render(nil)).to eq("") |
| 113 | end |
| 114 | end |