]> BookStack Code Mirror - bookstack/blob - tests/Uploads/AttachmentTest.php
Attachments: Hid edit/delete controls where lacking permission
[bookstack] / tests / Uploads / AttachmentTest.php
1 <?php
2
3 namespace Tests\Uploads;
4
5 use BookStack\Entities\Models\Page;
6 use BookStack\Entities\Repos\PageRepo;
7 use BookStack\Entities\Tools\TrashCan;
8 use BookStack\Uploads\Attachment;
9 use Tests\TestCase;
10
11 class AttachmentTest extends TestCase
12 {
13     public function test_file_upload()
14     {
15         $page = $this->entities->page();
16         $this->asAdmin();
17         $admin = $this->users->admin();
18         $fileName = 'upload_test_file.txt';
19
20         $expectedResp = [
21             'name'       => $fileName,
22             'uploaded_to' => $page->id,
23             'extension'  => 'txt',
24             'order'      => 1,
25             'created_by' => $admin->id,
26             'updated_by' => $admin->id,
27         ];
28
29         $upload = $this->files->uploadAttachmentFile($this, $fileName, $page->id);
30         $upload->assertStatus(200);
31
32         $attachment = Attachment::query()->orderBy('id', 'desc')->first();
33         $upload->assertJson($expectedResp);
34
35         $expectedResp['path'] = $attachment->path;
36         $this->assertDatabaseHas('attachments', $expectedResp);
37
38         $this->files->deleteAllAttachmentFiles();
39     }
40
41     public function test_file_upload_does_not_use_filename()
42     {
43         $page = $this->entities->page();
44         $fileName = 'upload_test_file.txt';
45
46         $this->asAdmin();
47         $upload = $this->files->uploadAttachmentFile($this, $fileName, $page->id);
48         $upload->assertStatus(200);
49
50         $attachment = Attachment::query()->orderBy('id', 'desc')->first();
51         $this->assertStringNotContainsString($fileName, $attachment->path);
52         $this->assertStringEndsWith('-txt', $attachment->path);
53         $this->files->deleteAllAttachmentFiles();
54     }
55
56     public function test_file_display_and_access()
57     {
58         $page = $this->entities->page();
59         $this->asAdmin();
60         $fileName = 'upload_test_file.txt';
61
62         $upload = $this->files->uploadAttachmentFile($this, $fileName, $page->id);
63         $upload->assertStatus(200);
64         $attachment = Attachment::orderBy('id', 'desc')->take(1)->first();
65
66         $pageGet = $this->get($page->getUrl());
67         $pageGet->assertSeeText($fileName);
68         $pageGet->assertSee($attachment->getUrl());
69
70         $attachmentGet = $this->get($attachment->getUrl());
71         $content = $attachmentGet->streamedContent();
72         $this->assertStringContainsString('Hi, This is a test file for testing the upload process.', $content);
73
74         $this->files->deleteAllAttachmentFiles();
75     }
76
77     public function test_attaching_link_to_page()
78     {
79         $page = $this->entities->page();
80         $admin = $this->users->admin();
81         $this->asAdmin();
82
83         $linkReq = $this->call('POST', 'attachments/link', [
84             'attachment_link_url'         => 'https://example.com',
85             'attachment_link_name'        => 'Example Attachment Link',
86             'attachment_link_uploaded_to' => $page->id,
87         ]);
88
89         $expectedData = [
90             'path'        => 'https://example.com',
91             'name'        => 'Example Attachment Link',
92             'uploaded_to' => $page->id,
93             'created_by'  => $admin->id,
94             'updated_by'  => $admin->id,
95             'external'    => true,
96             'order'       => 1,
97             'extension'   => '',
98         ];
99
100         $linkReq->assertStatus(200);
101         $this->assertDatabaseHas('attachments', $expectedData);
102         $attachment = Attachment::orderBy('id', 'desc')->take(1)->first();
103
104         $pageGet = $this->get($page->getUrl());
105         $pageGet->assertSeeText('Example Attachment Link');
106         $pageGet->assertSee($attachment->getUrl());
107
108         $attachmentGet = $this->get($attachment->getUrl());
109         $attachmentGet->assertRedirect('https://example.com');
110
111         $this->files->deleteAllAttachmentFiles();
112     }
113
114     public function test_attaching_long_links_to_a_page()
115     {
116         $page = $this->entities->page();
117
118         $link = 'https://example.com?query=' . str_repeat('catsIScool', 195);
119         $linkReq = $this->asAdmin()->post('attachments/link', [
120             'attachment_link_url'         => $link,
121             'attachment_link_name'        => 'Example Attachment Link',
122             'attachment_link_uploaded_to' => $page->id,
123         ]);
124
125         $linkReq->assertStatus(200);
126         $this->assertDatabaseHas('attachments', [
127             'uploaded_to' => $page->id,
128             'path' => $link,
129             'external' => true,
130         ]);
131
132         $attachment = $page->attachments()->where('external', '=', true)->first();
133         $resp = $this->get($attachment->getUrl());
134         $resp->assertRedirect($link);
135     }
136
137     public function test_attachment_updating()
138     {
139         $page = $this->entities->page();
140         $this->asAdmin();
141
142         $attachment = Attachment::factory()->create(['uploaded_to' => $page->id]);
143         $update = $this->call('PUT', 'attachments/' . $attachment->id, [
144             'attachment_edit_name' => 'My new attachment name',
145             'attachment_edit_url'  => 'https://test.example.com',
146         ]);
147
148         $expectedData = [
149             'id'          => $attachment->id,
150             'path'        => 'https://test.example.com',
151             'name'        => 'My new attachment name',
152             'uploaded_to' => $page->id,
153         ];
154
155         $update->assertStatus(200);
156         $this->assertDatabaseHas('attachments', $expectedData);
157
158         $this->files->deleteAllAttachmentFiles();
159     }
160
161     public function test_file_deletion()
162     {
163         $page = $this->entities->page();
164         $this->asAdmin();
165         $fileName = 'deletion_test.txt';
166         $this->files->uploadAttachmentFile($this, $fileName, $page->id);
167
168         $attachment = Attachment::query()->orderBy('id', 'desc')->first();
169         $filePath = storage_path($attachment->path);
170         $this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist');
171
172         $attachment = Attachment::first();
173         $this->delete($attachment->getUrl());
174
175         $this->assertDatabaseMissing('attachments', [
176             'name' => $fileName,
177         ]);
178         $this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected');
179
180         $this->files->deleteAllAttachmentFiles();
181     }
182
183     public function test_attachment_deletion_on_page_deletion()
184     {
185         $page = $this->entities->page();
186         $this->asAdmin();
187         $fileName = 'deletion_test.txt';
188         $this->files->uploadAttachmentFile($this, $fileName, $page->id);
189
190         $attachment = Attachment::query()->orderBy('id', 'desc')->first();
191         $filePath = storage_path($attachment->path);
192
193         $this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist');
194         $this->assertDatabaseHas('attachments', [
195             'name' => $fileName,
196         ]);
197
198         app(PageRepo::class)->destroy($page);
199         app(TrashCan::class)->empty();
200
201         $this->assertDatabaseMissing('attachments', [
202             'name' => $fileName,
203         ]);
204         $this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected');
205
206         $this->files->deleteAllAttachmentFiles();
207     }
208
209     public function test_attachment_access_without_permission_shows_404()
210     {
211         $admin = $this->users->admin();
212         $viewer = $this->users->viewer();
213         $page = $this->entities->page(); /** @var Page $page */
214         $this->actingAs($admin);
215         $fileName = 'permission_test.txt';
216         $this->files->uploadAttachmentFile($this, $fileName, $page->id);
217         $attachment = Attachment::orderBy('id', 'desc')->take(1)->first();
218
219         $this->permissions->setEntityPermissions($page, [], []);
220
221         $this->actingAs($viewer);
222         $attachmentGet = $this->get($attachment->getUrl());
223         $attachmentGet->assertStatus(404);
224         $attachmentGet->assertSee('Attachment not found');
225
226         $this->files->deleteAllAttachmentFiles();
227     }
228
229     public function test_data_and_js_links_cannot_be_attached_to_a_page()
230     {
231         $page = $this->entities->page();
232         $this->asAdmin();
233
234         $badLinks = [
235             'javascript:alert("bunny")',
236             ' javascript:alert("bunny")',
237             'JavaScript:alert("bunny")',
238             "\t\n\t\nJavaScript:alert(\"bunny\")",
239             'data:text/html;<a></a>',
240             'Data:text/html;<a></a>',
241             'Data:text/html;<a></a>',
242         ];
243
244         foreach ($badLinks as $badLink) {
245             $linkReq = $this->post('attachments/link', [
246                 'attachment_link_url'         => $badLink,
247                 'attachment_link_name'        => 'Example Attachment Link',
248                 'attachment_link_uploaded_to' => $page->id,
249             ]);
250             $linkReq->assertStatus(422);
251             $this->assertDatabaseMissing('attachments', [
252                 'path' => $badLink,
253             ]);
254         }
255
256         $attachment = Attachment::factory()->create(['uploaded_to' => $page->id]);
257
258         foreach ($badLinks as $badLink) {
259             $linkReq = $this->put('attachments/' . $attachment->id, [
260                 'attachment_edit_url'  => $badLink,
261                 'attachment_edit_name' => 'Example Attachment Link',
262             ]);
263             $linkReq->assertStatus(422);
264             $this->assertDatabaseMissing('attachments', [
265                 'path' => $badLink,
266             ]);
267         }
268     }
269
270     public function test_attachment_delete_only_shows_with_permission()
271     {
272         $this->asAdmin();
273         $page = $this->entities->page();
274         $this->files->uploadAttachmentFile($this, 'upload_test.txt', $page->id);
275         $attachment = $page->attachments()->first();
276         $viewer = $this->users->viewer();
277
278         $this->permissions->grantUserRolePermissions($viewer, ['page-update-all', 'attachment-create-all']);
279
280         $resp = $this->actingAs($viewer)->get($page->getUrl('/edit'));
281         $html = $this->withHtml($resp);
282         $html->assertElementExists(".card[data-id=\"{$attachment->id}\"]");
283         $html->assertElementNotExists(".card[data-id=\"{$attachment->id}\"] button[title=\"Delete\"]");
284
285         $this->permissions->grantUserRolePermissions($viewer, ['attachment-delete-all']);
286
287         $resp = $this->actingAs($viewer)->get($page->getUrl('/edit'));
288         $html = $this->withHtml($resp);
289         $html->assertElementExists(".card[data-id=\"{$attachment->id}\"] button[title=\"Delete\"]");
290     }
291
292     public function test_attachment_edit_only_shows_with_permission()
293     {
294         $this->asAdmin();
295         $page = $this->entities->page();
296         $this->files->uploadAttachmentFile($this, 'upload_test.txt', $page->id);
297         $attachment = $page->attachments()->first();
298         $viewer = $this->users->viewer();
299
300         $this->permissions->grantUserRolePermissions($viewer, ['page-update-all', 'attachment-create-all']);
301
302         $resp = $this->actingAs($viewer)->get($page->getUrl('/edit'));
303         $html = $this->withHtml($resp);
304         $html->assertElementExists(".card[data-id=\"{$attachment->id}\"]");
305         $html->assertElementNotExists(".card[data-id=\"{$attachment->id}\"] button[title=\"Edit\"]");
306
307         $this->permissions->grantUserRolePermissions($viewer, ['attachment-update-all']);
308
309         $resp = $this->actingAs($viewer)->get($page->getUrl('/edit'));
310         $html = $this->withHtml($resp);
311         $html->assertElementExists(".card[data-id=\"{$attachment->id}\"] button[title=\"Edit\"]");
312     }
313
314     public function test_file_access_with_open_query_param_provides_inline_response_with_correct_content_type()
315     {
316         $page = $this->entities->page();
317         $this->asAdmin();
318         $fileName = 'upload_test_file.txt';
319
320         $upload = $this->files->uploadAttachmentFile($this, $fileName, $page->id);
321         $upload->assertStatus(200);
322         $attachment = Attachment::query()->orderBy('id', 'desc')->take(1)->first();
323
324         $attachmentGet = $this->get($attachment->getUrl(true));
325         // http-foundation/Response does some 'fixing' of responses to add charsets to text responses.
326         $attachmentGet->assertHeader('Content-Type', 'text/plain; charset=UTF-8');
327         $attachmentGet->assertHeader('Content-Disposition', 'inline; filename="upload_test_file.txt"');
328         $attachmentGet->assertHeader('X-Content-Type-Options', 'nosniff');
329
330         $this->files->deleteAllAttachmentFiles();
331     }
332
333     public function test_html_file_access_with_open_forces_plain_content_type()
334     {
335         $page = $this->entities->page();
336         $this->asAdmin();
337
338         $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'test_file.html', '<html></html><p>testing</p>', 'text/html');
339
340         $attachmentGet = $this->get($attachment->getUrl(true));
341         // http-foundation/Response does some 'fixing' of responses to add charsets to text responses.
342         $attachmentGet->assertHeader('Content-Type', 'text/plain; charset=UTF-8');
343         $attachmentGet->assertHeader('Content-Disposition', 'inline; filename="test_file.html"');
344
345         $this->files->deleteAllAttachmentFiles();
346     }
347
348     public function test_file_upload_works_when_local_secure_restricted_is_in_use()
349     {
350         config()->set('filesystems.attachments', 'local_secure_restricted');
351
352         $page = $this->entities->page();
353         $fileName = 'upload_test_file.txt';
354
355         $this->asAdmin();
356         $upload = $this->files->uploadAttachmentFile($this, $fileName, $page->id);
357         $upload->assertStatus(200);
358
359         $attachment = Attachment::query()->orderBy('id', 'desc')->where('uploaded_to', '=', $page->id)->first();
360         $this->assertFileExists(storage_path($attachment->path));
361         $this->files->deleteAllAttachmentFiles();
362     }
363
364     public function test_file_get_range_access()
365     {
366         $page = $this->entities->page();
367         $this->asAdmin();
368         $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'my_text.txt', 'abc123456', 'text/plain');
369
370         // Download access
371         $resp = $this->get($attachment->getUrl(), ['Range' => 'bytes=3-5']);
372         $resp->assertStatus(206);
373         $resp->assertStreamedContent('123');
374         $resp->assertHeader('Content-Length', '3');
375         $resp->assertHeader('Content-Range', 'bytes 3-5/9');
376
377         // Inline access
378         $resp = $this->get($attachment->getUrl(true), ['Range' => 'bytes=5-7']);
379         $resp->assertStatus(206);
380         $resp->assertStreamedContent('345');
381         $resp->assertHeader('Content-Length', '3');
382         $resp->assertHeader('Content-Range', 'bytes 5-7/9');
383
384         $this->files->deleteAllAttachmentFiles();
385     }
386
387     public function test_file_head_range_returns_no_content()
388     {
389         $page = $this->entities->page();
390         $this->asAdmin();
391         $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'my_text.txt', 'abc123456', 'text/plain');
392
393         $resp = $this->head($attachment->getUrl(), ['Range' => 'bytes=0-9']);
394         $resp->assertStreamedContent('');
395         $resp->assertHeader('Content-Length', '9');
396         $resp->assertStatus(200);
397
398         $this->files->deleteAllAttachmentFiles();
399     }
400
401     public function test_file_head_range_edge_cases()
402     {
403         $page = $this->entities->page();
404         $this->asAdmin();
405
406         // Mime-type "sniffing" happens on first 2k bytes, hence this content (2005 bytes)
407         $content = '01234' . str_repeat('a', 1990) . '0123456789';
408         $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'my_text.txt', $content, 'text/plain');
409
410         // Test for both inline and download attachment serving
411         foreach ([true, false] as $isInline) {
412             // No end range
413             $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=5-']);
414             $resp->assertStreamedContent(substr($content, 5));
415             $resp->assertHeader('Content-Length', '2000');
416             $resp->assertHeader('Content-Range', 'bytes 5-2004/2005');
417             $resp->assertStatus(206);
418
419             // End only range
420             $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=-10']);
421             $resp->assertStreamedContent('0123456789');
422             $resp->assertHeader('Content-Length', '10');
423             $resp->assertHeader('Content-Range', 'bytes 1995-2004/2005');
424             $resp->assertStatus(206);
425
426             // Range across sniff point
427             $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=1997-2002']);
428             $resp->assertStreamedContent('234567');
429             $resp->assertHeader('Content-Length', '6');
430             $resp->assertHeader('Content-Range', 'bytes 1997-2002/2005');
431             $resp->assertStatus(206);
432
433             // Range up to sniff point
434             $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=0-1997']);
435             $resp->assertHeader('Content-Length', '1998');
436             $resp->assertHeader('Content-Range', 'bytes 0-1997/2005');
437             $resp->assertStreamedContent(substr($content, 0, 1998));
438             $resp->assertStatus(206);
439
440             // Range beyond sniff point
441             $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=2001-2003']);
442             $resp->assertStreamedContent('678');
443             $resp->assertHeader('Content-Length', '3');
444             $resp->assertHeader('Content-Range', 'bytes 2001-2003/2005');
445             $resp->assertStatus(206);
446
447             // Range beyond content
448             $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=0-2010']);
449             $resp->assertStreamedContent($content);
450             $resp->assertHeader('Content-Length', '2005');
451             $resp->assertHeader('Content-Range', 'bytes 0-2004/2005');
452             $resp->assertStatus(206);
453
454             // Range start before end
455             $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=50-10']);
456             $resp->assertStreamedContent($content);
457             $resp->assertHeader('Content-Length', '2005');
458             $resp->assertHeader('Content-Range', 'bytes */2005');
459             $resp->assertStatus(416);
460
461             // Full range request
462             $resp = $this->get($attachment->getUrl($isInline), ['Range' => 'bytes=0-']);
463             $resp->assertStreamedContent($content);
464             $resp->assertHeader('Content-Length', '2005');
465             $resp->assertHeader('Content-Range', 'bytes 0-2004/2005');
466             $resp->assertStatus(206);
467         }
468
469         $this->files->deleteAllAttachmentFiles();
470     }
471 }