| 1 | package wiki |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | func TestNormalizeFollowsNIP54Examples(t *testing.T) { |
| 10 | cases := map[string]string{ |
| 11 | "Wiki Article" : "wiki-article" , |
| 12 | "What's Up?" : "whats-up" , |
| 13 | " Hello World " : "hello-world" , |
| 14 | "Article 1" : "article-1" , |
| 15 | "ウィキペディア" : "ウィキペディア" , |
| 16 | "Ñoño" : "ñoño" , |
| 17 | "Москва" : "москва" , |
| 18 | "日本語 Article" : "日本語-article" , |
| 19 | "proof-of-work" : "proof-of-work" , |
| 20 | "--Lightning--" : "lightning" , |
| 21 | "a -- b\tc" : "a-b-c" , |
| 22 | "C++ & C#" : "c-c" , |
| 23 | "e\u0301lan" : "e\u0301lan" , |
| 24 | "" : "" , |
| 25 | "!!!" : "" , |
| 26 | "snake_case_name" : "snake-case-name" , |
| 27 | "Tabs\tand\nbreaks" : "tabs-and-breaks" , |
| 28 | } |
| 29 | for input, want := range cases { |
| 30 | if got := Normalize(input); got != want { |
| 31 | t.Errorf("Normalize(%q) = %q, want %q" , input, got, want) |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | func TestLinksFindWikilinksReferencesAndNostrTargets(t *testing.T) { |
| 37 | content := "Bitcoin is a [cryptocurrency][] invented by [Satoshi Nakamoto][].\n\n" + |
| 38 | "See also: [proof of work][] and [lightning network][Lightning Network], [[Mining|the miners]] and _[Биткойн][]_.\n\n" + |
| 39 | "- [Bob](nostr:npub1bob) wrote `[[not a link]]`\n" + |
| 40 | "- [web](https://example.com/[[x]])\n\n" + |
| 41 | "```\n[[ignored in code]]\n```\n\n" + |
| 42 | "[Satoshi Nakamoto]: nostr:npub1satoshi\n" |
| 43 | got := Links(content) |
| 44 | want := []string{"nostr:npub1satoshi" , "cryptocurrency" , "proof-of-work" , "lightning-network" , "mining" , "биткойн" , "nostr:npub1bob" } |
| 45 | if fmt.Sprint(got) != fmt.Sprint(want) { |
| 46 | t.Fatalf("Links = %v, want %v" , got, want) |
| 47 | } |
| 48 | if links := Links("plain text" ); len(links) != 0 { |
| 49 | t.Fatalf("plain text links = %v" , links) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func TestRenderHTMLSupportedSyntax(t *testing.T) { |
| 54 | content := "# Title with _emphasis_\n\n" + |
| 55 | "A paragraph with *strong*, `code <b>` and a [link](https://example.com/a?b=1&c=2).\n" + |
| 56 | "Second line of the paragraph.\n\n" + |
| 57 | "## Lists\n\n" + |
| 58 | "- one [[Wiki Page]]\n- two [[Другая|shown]]\n\n" + |
| 59 | "1. first\n2) second\n\n" + |
| 60 | "``` go\nfunc main() {}\n<script>alert(1)</script>\n```\n\n" + |
| 61 | "[Bob](nostr:npub1bob) and [reference][] and [defined][site].\n\n" + |
| 62 | "[site]: https://example.org/\n" |
| 63 | got := string(RenderHTML(content)) |
| 64 | for _, want := range []string{ |
| 65 | "<h1>Title with <em>emphasis</em></h1>" , |
| 66 | "<p>A paragraph with <strong>strong</strong>, <code>code <b></code> and a <a href=\"https://example.com/a?b=1&c=2\">link</a>.\nSecond line of the paragraph.</p>" , |
| 67 | "<h2>Lists</h2>" , |
| 68 | "<ul>\n<li>one <a href=\"/wiki/wiki-page\">Wiki Page</a></li>\n<li>two <a href=\"/wiki/%D0%B4%D1%80%D1%83%D0%B3%D0%B0%D1%8F\">shown</a></li>\n</ul>" , |
| 69 | "<ol>\n<li>first</li>\n<li>second</li>\n</ol>" , |
| 70 | "<pre><code data-lang=\"go\">func main() {}\n<script>alert(1)</script>\n</code></pre>" , |
| 71 | "<a href=\"/open?target=nostr%3Anpub1bob\">Bob</a>" , |
| 72 | "<a href=\"/wiki/reference\">reference</a>" , |
| 73 | "<a href=\"https://example.org/\">defined</a>" , |
| 74 | } { |
| 75 | if !strings.Contains(got, want) { |
| 76 | t.Errorf("missing %q in\n%s" , want, got) |
| 77 | } |
| 78 | } |
| 79 | if strings.Contains(got, "[site]" ) { |
| 80 | t.Errorf("definition line rendered:\n%s" , got) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestRenderHTMLEscapesEverythingElse(t *testing.T) { |
| 85 | cases := []struct{ content, forbidden, required string }{ |
| 86 | {"<script>alert(1)</script>" , "<script" , "<script>" }, |
| 87 | {"[click](javascript:alert(1))" , "javascript:" , "click" }, |
| 88 | {"[click](JavaScript:alert(1))" , "avaScript:" , "click" }, |
| 89 | {"[click](data:text/html;base64,AAAA)" , "data:" , "click" }, |
| 90 | {"[click](vbscript:msgbox)" , "vbscript:" , "click" }, |
| 91 | {"[click](//evil.example/x)" , "evil.example" , "click" }, |
| 92 | {"[click](/\\evil.example)" , "href" , "click" }, |
| 93 | {"[[Page]] [x](\"><img src=x onerror=alert(1)>)" , "<img" , "x>" }, |
| 94 | {"[ref][]\n\n[ref]: javascript:alert(1)" , "javascript:" , "<p>ref</p>" }, |
| 95 | {"<a href=\"https://x\">raw html</a>" , "<a href" , "<a href" }, |
| 96 | {"<img src=x onerror=alert(1)>" , "<img" , "<img" }, |
| 97 | {"_<b>bold</b>_" , "<b>" , "<em><b>bold</b></em>" }, |
| 98 | {"[[<script>]]" , "<script" , "/wiki/script" }, |
| 99 | {"[[x|<script>]]" , "<script>" , "<script>" }, |
| 100 | {"`<script>`" , "<script>" , "<code><script></code>" }, |
| 101 | {"[text](https://example.com/\"onmouseover=\"alert(1))" , "onmouseover=\"" , ""onmouseover="" }, |
| 102 | } |
| 103 | for _, tc := range cases { |
| 104 | got := string(RenderHTML(tc.content)) |
| 105 | if strings.Contains(got, tc.forbidden) { |
| 106 | t.Errorf("%q rendered %q:\n%s" , tc.content, tc.forbidden, got) |
| 107 | } |
| 108 | if !strings.Contains(got, tc.required) { |
| 109 | t.Errorf("%q lacks %q:\n%s" , tc.content, tc.required, got) |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func TestRenderHTMLHandlesEdgeCases(t *testing.T) { |
| 115 | cases := map[string]string{ |
| 116 | "" : "" , |
| 117 | "\n\n\n" : "" , |
| 118 | "```" : "<pre><code>\n</code></pre>\n" , |
| 119 | "```\nunterminated" : "<pre><code>unterminated\n</code></pre>\n" , |
| 120 | "#NoSpace" : "<p>#NoSpace</p>\n" , |
| 121 | "####### seven" : "<p>####### seven</p>\n" , |
| 122 | "* not strong" : "<ul>\n<li>not strong</li>\n</ul>\n" , |
| 123 | "a * b * c" : "<p>a * b * c</p>\n" , |
| 124 | "a_b_c" : "<p>a<em>b</em>c</p>\n" , |
| 125 | "\\*escaped\\*" : "<p>*escaped*</p>\n" , |
| 126 | "``code with ` inside``" : "<p><code>code with ` inside</code></p>\n" , |
| 127 | "[[]]" : "<p>[[]]</p>\n" , |
| 128 | "[unclosed" : "<p>[unclosed</p>\n" , |
| 129 | "[text](" : "<p>[text](</p>\n" , |
| 130 | "[mailto](mailto:a@b.example)" : "<p><a href=\"mailto:a@b.example\">mailto</a></p>\n" , |
| 131 | "[rel](/files)" : "<p><a href=\"/files\">rel</a></p>\n" , |
| 132 | "[bad](http://)" : "<p>bad</p>\n" , |
| 133 | "- item\nplain" : "<ul>\n<li>item</li>\n</ul>\n<p>plain</p>\n" , |
| 134 | "para\n- not a list" : "<p>para\n- not a list</p>\n" , |
| 135 | "Ünïcödé & <ampersand>" : "<p>Ünïcödé & <ampersand></p>\n" , |
| 136 | "line\r\nbreak" : "<p>line\nbreak</p>\n" , |
| 137 | } |
| 138 | for content, want := range cases { |
| 139 | if got := string(RenderHTML(content)); got != want { |
| 140 | t.Errorf("RenderHTML(%q) =\n%s\nwant\n%s" , content, got, want) |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | func TestRenderHTMLPipeTables(t *testing.T) { |
| 146 | got := string(RenderHTML("Before.\n| Proven | Activity | Evidence |\n| --- | :---: | ---: |\n| yes | Discovery, *auth* | NIP-98 succeeded. |\n| no | Wiki fork \\| merge | remains |\nAfter." )) |
| 147 | for _, want := range []string{ |
| 148 | "<p>Before.</p>\n<table>\n<thead>\n<tr><th>Proven</th><th>Activity</th><th>Evidence</th></tr>\n</thead>\n<tbody>\n" , |
| 149 | "<tr><td>yes</td><td>Discovery, <strong>auth</strong></td><td>NIP-98 succeeded.</td></tr>\n" , |
| 150 | "<tr><td>no</td><td>Wiki fork | merge</td><td>remains</td></tr>\n</tbody>\n</table>\n<p>After.</p>\n" , |
| 151 | } { |
| 152 | if !strings.Contains(got, want) { |
| 153 | t.Errorf("missing %q in %s" , want, got) |
| 154 | } |
| 155 | } |
| 156 | // A table without a separator is all body rows; a pipe inside a paragraph is text. |
| 157 | got = string(RenderHTML("| a | b |\n| c | d |\n\ntext | with a pipe" )) |
| 158 | if !strings.Contains(got, "<table>\n<tbody>\n<tr><td>a</td><td>b</td></tr>\n<tr><td>c</td><td>d</td></tr>\n</tbody>\n</table>" ) || !strings.Contains(got, "<p>text | with a pipe</p>" ) { |
| 159 | t.Fatalf("headerless table: %s" , got) |
| 160 | } |
| 161 | if strings.Contains(string(RenderHTML("| <script>x</script> |\n| --- |" )), "<script>" ) { |
| 162 | t.Fatal("table cells were not escaped" ) |
| 163 | } |
| 164 | } |