]> BookStack Code Mirror - bookstack/blob - tests/Entity/PageContentTest.php
Merge branch 'footer-links' of git://github.com/james-geiger/BookStack into james...
[bookstack] / tests / Entity / PageContentTest.php
1 <?php namespace Tests\Entity;
2
3 use BookStack\Entities\Tools\PageContent;
4 use BookStack\Entities\Models\Page;
5 use Tests\TestCase;
6
7 class PageContentTest extends TestCase
8 {
9
10     public function test_page_includes()
11     {
12         $page = Page::first();
13         $secondPage = Page::where('id', '!=', $page->id)->first();
14
15         $secondPage->html = "<p id='section1'>Hello, This is a test</p><p id='section2'>This is a second block of content</p>";
16         $secondPage->save();
17
18         $this->asEditor();
19
20         $pageContent = $this->get($page->getUrl());
21         $pageContent->assertDontSee('Hello, This is a test');
22
23         $originalHtml = $page->html;
24         $page->html .= "{{@{$secondPage->id}}}";
25         $page->save();
26
27         $pageContent = $this->get($page->getUrl());
28         $pageContent->assertSee('Hello, This is a test');
29         $pageContent->assertSee('This is a second block of content');
30
31         $page->html = $originalHtml . " Well {{@{$secondPage->id}#section2}}";
32         $page->save();
33
34         $pageContent = $this->get($page->getUrl());
35         $pageContent->assertDontSee('Hello, This is a test');
36         $pageContent->assertSee('Well This is a second block of content');
37     }
38
39     public function test_saving_page_with_includes()
40     {
41         $page = Page::first();
42         $secondPage = Page::where('id', '!=', $page->id)->first();
43
44         $this->asEditor();
45         $includeTag = '{{@' . $secondPage->id . '}}';
46         $page->html = '<p>' . $includeTag . '</p>';
47
48         $resp = $this->put($page->getUrl(), ['name' => $page->name, 'html' => $page->html, 'summary' => '']);
49
50         $resp->assertStatus(302);
51
52         $page = Page::find($page->id);
53         $this->assertStringContainsString($includeTag, $page->html);
54         $this->assertEquals('', $page->text);
55     }
56
57     public function test_page_includes_do_not_break_tables()
58     {
59         $page = Page::first();
60         $secondPage = Page::where('id', '!=', $page->id)->first();
61
62         $content = '<table id="table"><tbody><tr><td>test</td></tr></tbody></table>';
63         $secondPage->html = $content;
64         $secondPage->save();
65
66         $page->html = "{{@{$secondPage->id}#table}}";
67         $page->save();
68
69         $this->asEditor();
70         $pageResp = $this->get($page->getUrl());
71         $pageResp->assertSee($content);
72     }
73
74     public function test_page_includes_rendered_on_book_export()
75     {
76         $page = Page::query()->first();
77         $secondPage = Page::query()
78             ->where('book_id', '!=', $page->book_id)
79             ->first();
80
81         $content = '<p id="bkmrk-meow">my cat is awesome and scratchy</p>';
82         $secondPage->html = $content;
83         $secondPage->save();
84
85         $page->html = "{{@{$secondPage->id}#bkmrk-meow}}";
86         $page->save();
87
88         $this->asEditor();
89         $htmlContent = $this->get($page->book->getUrl('/export/html'));
90         $htmlContent->assertSee('my cat is awesome and scratchy');
91     }
92
93     public function test_page_content_scripts_removed_by_default()
94     {
95         $this->asEditor();
96         $page = Page::first();
97         $script = 'abc123<script>console.log("hello-test")</script>abc123';
98         $page->html = "escape {$script}";
99         $page->save();
100
101         $pageView = $this->get($page->getUrl());
102         $pageView->assertStatus(200);
103         $pageView->assertDontSee($script);
104         $pageView->assertSee('abc123abc123');
105     }
106
107     public function test_more_complex_content_script_escaping_scenarios()
108     {
109         $checks = [
110             "<p>Some script</p><script>alert('cat')</script>",
111             "<div><div><div><div><p>Some script</p><script>alert('cat')</script></div></div></div></div>",
112             "<p>Some script<script>alert('cat')</script></p>",
113             "<p>Some script <div><script>alert('cat')</script></div></p>",
114             "<p>Some script <script><div>alert('cat')</script></div></p>",
115             "<p>Some script <script><div>alert('cat')</script><script><div>alert('cat')</script></p><script><div>alert('cat')</script>",
116         ];
117
118         $this->asEditor();
119         $page = Page::first();
120
121         foreach ($checks as $check) {
122             $page->html = $check;
123             $page->save();
124
125             $pageView = $this->get($page->getUrl());
126             $pageView->assertStatus(200);
127             $pageView->assertElementNotContains('.page-content', '<script>');
128             $pageView->assertElementNotContains('.page-content', '</script>');
129         }
130
131     }
132
133     public function test_iframe_js_and_base64_urls_are_removed()
134     {
135         $checks = [
136             '<iframe src="javascript:alert(document.cookie)"></iframe>',
137             '<iframe SRC=" javascript: alert(document.cookie)"></iframe>',
138             '<iframe src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
139             '<iframe src=" data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
140             '<iframe srcdoc="<script>window.alert(document.cookie)</script>"></iframe>'
141         ];
142
143         $this->asEditor();
144         $page = Page::first();
145
146         foreach ($checks as $check) {
147             $page->html = $check;
148             $page->save();
149
150             $pageView = $this->get($page->getUrl());
151             $pageView->assertStatus(200);
152             $pageView->assertElementNotContains('.page-content', '<iframe>');
153             $pageView->assertElementNotContains('.page-content', '</iframe>');
154             $pageView->assertElementNotContains('.page-content', 'src=');
155             $pageView->assertElementNotContains('.page-content', 'javascript:');
156             $pageView->assertElementNotContains('.page-content', 'data:');
157             $pageView->assertElementNotContains('.page-content', 'base64');
158         }
159
160     }
161
162     public function test_javascript_uri_links_are_removed()
163     {
164         $checks = [
165             '<a id="xss" href="javascript:alert(document.cookie)>Click me</a>',
166             '<a id="xss" href="javascript: alert(document.cookie)>Click me</a>'
167         ];
168
169         $this->asEditor();
170         $page = Page::first();
171
172         foreach ($checks as $check) {
173             $page->html = $check;
174             $page->save();
175
176             $pageView = $this->get($page->getUrl());
177             $pageView->assertStatus(200);
178             $pageView->assertElementNotContains('.page-content', '<a id="xss">');
179             $pageView->assertElementNotContains('.page-content', 'href=javascript:');
180         }
181     }
182     public function test_form_actions_with_javascript_are_removed()
183     {
184         $checks = [
185             '<form><input id="xss" type=submit formaction=javascript:alert(document.domain) value=Submit><input></form>',
186             '<form ><button id="xss" formaction=javascript:alert(document.domain)>Click me</button></form>',
187             '<form id="xss" action=javascript:alert(document.domain)><input type=submit value=Submit></form>'
188         ];
189
190         $this->asEditor();
191         $page = Page::first();
192
193         foreach ($checks as $check) {
194             $page->html = $check;
195             $page->save();
196
197             $pageView = $this->get($page->getUrl());
198             $pageView->assertStatus(200);
199             $pageView->assertElementNotContains('.page-content', '<button id="xss"');
200             $pageView->assertElementNotContains('.page-content', '<input id="xss"');
201             $pageView->assertElementNotContains('.page-content', '<form id="xss"');
202             $pageView->assertElementNotContains('.page-content', 'action=javascript:');
203             $pageView->assertElementNotContains('.page-content', 'formaction=javascript:');
204         }
205     }
206     
207     public function test_metadata_redirects_are_removed()
208     {
209         $checks = [
210             '<meta http-equiv="refresh" content="0; url=//external_url">',
211         ];
212
213         $this->asEditor();
214         $page = Page::first();
215
216         foreach ($checks as $check) {
217             $page->html = $check;
218             $page->save();
219
220             $pageView = $this->get($page->getUrl());
221             $pageView->assertStatus(200);
222             $pageView->assertElementNotContains('.page-content', '<meta>');
223             $pageView->assertElementNotContains('.page-content', '</meta>');
224             $pageView->assertElementNotContains('.page-content', 'content=');
225             $pageView->assertElementNotContains('.page-content', 'external_url');
226         }
227     }
228     public function test_page_inline_on_attributes_removed_by_default()
229     {
230         $this->asEditor();
231         $page = Page::first();
232         $script = '<p onmouseenter="console.log(\'test\')">Hello</p>';
233         $page->html = "escape {$script}";
234         $page->save();
235
236         $pageView = $this->get($page->getUrl());
237         $pageView->assertStatus(200);
238         $pageView->assertDontSee($script);
239         $pageView->assertSee('<p>Hello</p>');
240     }
241
242     public function test_more_complex_inline_on_attributes_escaping_scenarios()
243     {
244         $checks = [
245             '<p onclick="console.log(\'test\')">Hello</p>',
246             '<div>Lorem ipsum dolor sit amet.</div><p onclick="console.log(\'test\')">Hello</p>',
247             '<div>Lorem ipsum dolor sit amet.<p onclick="console.log(\'test\')">Hello</p></div>',
248             '<div><div><div><div>Lorem ipsum dolor sit amet.<p onclick="console.log(\'test\')">Hello</p></div></div></div></div>',
249             '<div onclick="console.log(\'test\')">Lorem ipsum dolor sit amet.</div><p onclick="console.log(\'test\')">Hello</p><div></div>',
250             '<a a="<img src=1 onerror=\'alert(1)\'> ',
251         ];
252
253         $this->asEditor();
254         $page = Page::first();
255
256         foreach ($checks as $check) {
257             $page->html = $check;
258             $page->save();
259
260             $pageView = $this->get($page->getUrl());
261             $pageView->assertStatus(200);
262             $pageView->assertElementNotContains('.page-content', 'onclick');
263         }
264
265     }
266
267     public function test_page_content_scripts_show_when_configured()
268     {
269         $this->asEditor();
270         $page = Page::first();
271         config()->push('app.allow_content_scripts', 'true');
272
273         $script = 'abc123<script>console.log("hello-test")</script>abc123';
274         $page->html = "no escape {$script}";
275         $page->save();
276
277         $pageView = $this->get($page->getUrl());
278         $pageView->assertSee($script);
279         $pageView->assertDontSee('abc123abc123');
280     }
281
282     public function test_page_inline_on_attributes_show_if_configured()
283     {
284         $this->asEditor();
285         $page = Page::first();
286         config()->push('app.allow_content_scripts', 'true');
287
288         $script = '<p onmouseenter="console.log(\'test\')">Hello</p>';
289         $page->html = "escape {$script}";
290         $page->save();
291
292         $pageView = $this->get($page->getUrl());
293         $pageView->assertSee($script);
294         $pageView->assertDontSee('<p>Hello</p>');
295     }
296
297     public function test_duplicate_ids_does_not_break_page_render()
298     {
299         $this->asEditor();
300         $pageA = Page::first();
301         $pageB = Page::query()->where('id', '!=', $pageA->id)->first();
302
303         $content = '<ul id="bkmrk-xxx-%28"></ul> <ul id="bkmrk-xxx-%28"></ul>';
304         $pageA->html = $content;
305         $pageA->save();
306
307         $pageB->html = '<ul id="bkmrk-xxx-%28"></ul> <p>{{@'. $pageA->id .'#test}}</p>';
308         $pageB->save();
309
310         $pageView = $this->get($pageB->getUrl());
311         $pageView->assertSuccessful();
312     }
313
314     public function test_duplicate_ids_fixed_on_page_save()
315     {
316         $this->asEditor();
317         $page = Page::first();
318
319         $content = '<ul id="bkmrk-test"><li>test a</li><li><ul id="bkmrk-test"><li>test b</li></ul></li></ul>';
320         $pageSave = $this->put($page->getUrl(), [
321             'name' => $page->name,
322             'html' => $content,
323             'summary' => ''
324         ]);
325         $pageSave->assertRedirect();
326
327         $updatedPage = Page::where('id', '=', $page->id)->first();
328         $this->assertEquals(substr_count($updatedPage->html, "bkmrk-test\""), 1);
329     }
330
331     public function test_anchors_referencing_non_bkmrk_ids_rewritten_after_save()
332     {
333         $this->asEditor();
334         $page = Page::first();
335
336         $content = '<h1 id="non-standard-id">test</h1><p><a href="#non-standard-id">link</a></p>';
337         $this->put($page->getUrl(), [
338             'name' => $page->name,
339             'html' => $content,
340             'summary' => ''
341         ]);
342
343         $updatedPage = Page::where('id', '=', $page->id)->first();
344         $this->assertStringContainsString('id="bkmrk-test"', $updatedPage->html);
345         $this->assertStringContainsString('href="#bkmrk-test"', $updatedPage->html);
346     }
347
348     public function test_get_page_nav_sets_correct_properties()
349     {
350         $content = '<h1 id="testa">Hello</h1><h2 id="testb">There</h2><h3 id="testc">Donkey</h3>';
351         $pageContent = new PageContent(new Page(['html' => $content]));
352         $navMap = $pageContent->getNavigation($content);
353
354         $this->assertCount(3, $navMap);
355         $this->assertArrayMapIncludes([
356             'nodeName' => 'h1',
357             'link' => '#testa',
358             'text' => 'Hello',
359             'level' => 1,
360         ], $navMap[0]);
361         $this->assertArrayMapIncludes([
362             'nodeName' => 'h2',
363             'link' => '#testb',
364             'text' => 'There',
365             'level' => 2,
366         ], $navMap[1]);
367         $this->assertArrayMapIncludes([
368             'nodeName' => 'h3',
369             'link' => '#testc',
370             'text' => 'Donkey',
371             'level' => 3,
372         ], $navMap[2]);
373     }
374
375     public function test_get_page_nav_does_not_show_empty_titles()
376     {
377         $content = '<h1 id="testa">Hello</h1><h2 id="testb">&nbsp;</h2><h3 id="testc"></h3>';
378         $pageContent = new PageContent(new Page(['html' => $content]));
379         $navMap = $pageContent->getNavigation($content);
380
381         $this->assertCount(1, $navMap);
382         $this->assertArrayMapIncludes([
383             'nodeName' => 'h1',
384             'link' => '#testa',
385             'text' => 'Hello'
386         ], $navMap[0]);
387     }
388
389     public function test_get_page_nav_shifts_headers_if_only_smaller_ones_are_used()
390     {
391         $content = '<h4 id="testa">Hello</h4><h5 id="testb">There</h5><h6 id="testc">Donkey</h6>';
392         $pageContent = new PageContent(new Page(['html' => $content]));
393         $navMap = $pageContent->getNavigation($content);
394
395         $this->assertCount(3, $navMap);
396         $this->assertArrayMapIncludes([
397             'nodeName' => 'h4',
398             'level' => 1,
399         ], $navMap[0]);
400         $this->assertArrayMapIncludes([
401             'nodeName' => 'h5',
402             'level' => 2,
403         ], $navMap[1]);
404         $this->assertArrayMapIncludes([
405             'nodeName' => 'h6',
406             'level' => 3,
407         ], $navMap[2]);
408     }
409
410     public function test_page_text_decodes_html_entities()
411     {
412         $page = Page::query()->first();
413
414         $this->actingAs($this->getAdmin())
415             ->put($page->getUrl(''), [
416                 'name' => 'Testing',
417                 'html' => '<p>&quot;Hello &amp; welcome&quot;</p>',
418             ]);
419
420         $page->refresh();
421         $this->assertEquals('"Hello & welcome"', $page->text);
422     }
423
424     public function test_page_markdown_table_rendering()
425     {
426         $this->asEditor();
427         $page = Page::query()->first();
428
429         $content = '| Syntax      | Description |
430 | ----------- | ----------- |
431 | Header      | Title       |
432 | Paragraph   | Text        |';
433         $this->put($page->getUrl(), [
434             'name' => $page->name,  'markdown' => $content,
435             'html' => '', 'summary' => ''
436         ]);
437
438         $page->refresh();
439         $this->assertStringContainsString('</tbody>', $page->html);
440
441         $pageView = $this->get($page->getUrl());
442         $pageView->assertElementExists('.page-content table tbody td');
443     }
444
445     public function test_page_markdown_task_list_rendering()
446     {
447         $this->asEditor();
448         $page = Page::query()->first();
449
450         $content = '- [ ] Item a
451 - [x] Item b';
452         $this->put($page->getUrl(), [
453             'name' => $page->name,  'markdown' => $content,
454             'html' => '', 'summary' => ''
455         ]);
456
457         $page->refresh();
458         $this->assertStringContainsString('input', $page->html);
459         $this->assertStringContainsString('type="checkbox"', $page->html);
460
461         $pageView = $this->get($page->getUrl());
462         $pageView->assertElementExists('.page-content input[type=checkbox]');
463     }
464
465     public function test_page_markdown_strikethrough_rendering()
466     {
467         $this->asEditor();
468         $page = Page::query()->first();
469
470         $content = '~~some crossed out text~~';
471         $this->put($page->getUrl(), [
472             'name' => $page->name,  'markdown' => $content,
473             'html' => '', 'summary' => ''
474         ]);
475
476         $page->refresh();
477         $this->assertStringMatchesFormat('%A<s%A>some crossed out text</s>%A', $page->html);
478
479         $pageView = $this->get($page->getUrl());
480         $pageView->assertElementExists('.page-content p > s');
481     }
482 }