SaveUploadTest.php 18.8 KB
Newer Older
1 2
<?php

3
namespace Drupal\Tests\file\Functional;
4

5
use Drupal\Component\Render\FormattableMarkup;
6
use Drupal\Core\File\FileSystemInterface;
7
use Drupal\Core\Url;
8
use Drupal\file\Entity\File;
9
use Drupal\Tests\TestFileCreationTrait;
10

11
/**
12 13 14
 * Tests the file_save_upload() function.
 *
 * @group file
15
 */
16
class SaveUploadTest extends FileManagedTestBase {
17 18 19 20 21

  use TestFileCreationTrait {
    getTestFiles as drupalGetTestFiles;
  }

22 23 24 25 26
  /**
   * Modules to enable.
   *
   * @var array
   */
27
  protected static $modules = ['dblog'];
28

29 30 31 32 33
  /**
   * {@inheritdoc}
   */
  protected $defaultTheme = 'stark';

34 35
  /**
   * An image file path for uploading.
36 37
   *
   * @var \Drupal\file\FileInterface
38 39 40 41 42
   */
  protected $image;

  /**
   * A PHP file path for upload security testing.
43 44
   *
   * @var string
45 46 47 48 49
   */
  protected $phpfile;

  /**
   * The largest file id when the test starts.
50 51
   *
   * @var int
52 53 54
   */
  protected $maxFidBefore;

55 56 57 58 59 60 61
  /**
   * Extension of the image filename.
   *
   * @var string
   */
  protected $imageExtension;

62
  protected function setUp(): void {
63
    parent::setUp();
64
    $account = $this->drupalCreateUser(['access site reports']);
65 66 67
    $this->drupalLogin($account);

    $image_files = $this->drupalGetTestFiles('image');
68
    $this->image = File::create((array) current($image_files));
69

70
    list(, $this->imageExtension) = explode('.', $this->image->getFilename());
71
    $this->assertFileExists($this->image->getFileUri());
72 73

    $this->phpfile = current($this->drupalGetTestFiles('php'));
74
    $this->assertFileExists($this->phpfile->uri);
75

76
    $this->maxFidBefore = (int) \Drupal::entityQueryAggregate('file')->aggregate('fid', 'max')->execute()[0]['fid_max'];
77 78

    // Upload with replace to guarantee there's something there.
79
    $edit = [
80
      'file_test_replace' => FileSystemInterface::EXISTS_REPLACE,
81
      'files[file_test_upload]' => \Drupal::service('file_system')->realpath($this->image->getFileUri()),
82
    ];
83
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
84 85
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
    $this->assertRaw(t('You WIN!'), 'Found the success message.');
86 87 88

    // Check that the correct hooks were called then clean out the hook
    // counters.
89
    $this->assertFileHooksCalled(['validate', 'insert']);
90 91 92 93 94 95
    file_test_reset();
  }

  /**
   * Test the file_save_upload() function.
   */
96
  public function testNormal() {
97
    $max_fid_after = (int) \Drupal::entityQueryAggregate('file')->aggregate('fid', 'max')->execute()[0]['fid_max'];
98
    $this->assertTrue($max_fid_after > $this->maxFidBefore, 'A new file was created.');
99
    $file1 = File::load($max_fid_after);
100
    $this->assertInstanceOf(File::class, $file1);
101
    // MIME type of the uploaded image may be either image/jpeg or image/png.
102
    $this->assertEqual(substr($file1->getMimeType(), 0, 5), 'image', 'A MIME type was set.');
103 104 105 106 107 108

    // Reset the hook counters to get rid of the 'load' we just called.
    file_test_reset();

    // Upload a second file.
    $image2 = current($this->drupalGetTestFiles('image'));
109
    $edit = ['files[file_test_upload]' => \Drupal::service('file_system')->realpath($image2->uri)];
110
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
111
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
112
    $this->assertRaw(t('You WIN!'));
113
    $max_fid_after = (int) \Drupal::entityQueryAggregate('file')->aggregate('fid', 'max')->execute()[0]['fid_max'];
114 115

    // Check that the correct hooks were called.
116
    $this->assertFileHooksCalled(['validate', 'insert']);
117

118
    $file2 = File::load($max_fid_after);
119
    $this->assertInstanceOf(File::class, $file2);
120
    // MIME type of the uploaded image may be either image/jpeg or image/png.
121
    $this->assertEqual(substr($file2->getMimeType(), 0, 5), 'image', 'A MIME type was set.');
122

123
    // Load both files using File::loadMultiple().
124
    $files = File::loadMultiple([$file1->id(), $file2->id()]);
125 126
    $this->assertTrue(isset($files[$file1->id()]), 'File was loaded successfully');
    $this->assertTrue(isset($files[$file2->id()]), 'File was loaded successfully');
127 128 129

    // Upload a third file to a subdirectory.
    $image3 = current($this->drupalGetTestFiles('image'));
130
    $image3_realpath = \Drupal::service('file_system')->realpath($image3->uri);
131
    $dir = $this->randomMachineName();
132
    $edit = [
133 134
      'files[file_test_upload]' => $image3_realpath,
      'file_subdir' => $dir,
135
    ];
136
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
137
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
138
    $this->assertRaw(t('You WIN!'));
139
    $this->assertFileExists('temporary://' . $dir . '/' . trim(\Drupal::service('file_system')->basename($image3_realpath)));
140 141
  }

142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170
  /**
   * Test uploading a duplicate file.
   */
  public function testDuplicate() {
    // It should not be possible to create two managed files with the same URI.
    $image1 = current($this->drupalGetTestFiles('image'));
    $edit = ['files[file_test_upload]' => \Drupal::service('file_system')->realpath($image1->uri)];
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
    $max_fid_after = (int) \Drupal::entityQueryAggregate('file')->aggregate('fid', 'max')->execute()[0]['fid_max'];
    $file1 = File::load($max_fid_after);

    // Simulate a race condition where two files are uploaded at almost the same
    // time, by removing the first uploaded file from disk (leaving the entry in
    // the file_managed table) before trying to upload another file with the
    // same name.
    unlink(\Drupal::service('file_system')->realpath($file1->getFileUri()));

    $image2 = $image1;
    $edit = ['files[file_test_upload]' => \Drupal::service('file_system')->realpath($image2->uri)];
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
    // Received a 200 response for posted test file.
    $this->assertResponse(200);
    $message = t('The file %file already exists. Enter a unique file URI.', ['%file' => $file1->getFileUri()]);
    $this->assertRaw($message);
    $max_fid_before_duplicate = $max_fid_after;
    $max_fid_after = (int) \Drupal::entityQueryAggregate('file')->aggregate('fid', 'max')->execute()[0]['fid_max'];
    $this->assertEqual($max_fid_before_duplicate, $max_fid_after, 'A new managed file was not created.');
  }

171 172 173
  /**
   * Test extension handling.
   */
174
  public function testHandleExtension() {
175 176 177 178 179
    // The file being tested is a .gif which is in the default safe list
    // of extensions to allow when the extension validator isn't used. This is
    // implicitly tested at the testNormal() test. Here we tell
    // file_save_upload() to only allow ".foo".
    $extensions = 'foo';
180
    $edit = [
181
      'file_test_replace' => FileSystemInterface::EXISTS_REPLACE,
182
      'files[file_test_upload]' => \Drupal::service('file_system')->realpath($this->image->getFileUri()),
183
      'extensions' => $extensions,
184
    ];
185

186
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
187
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
188
    $message = t('Only files with the following extensions are allowed:') . ' <em class="placeholder">' . $extensions . '</em>';
189 190
    $this->assertRaw($message, 'Cannot upload a disallowed extension');
    $this->assertRaw(t('Epic upload FAIL!'), 'Found the failure message.');
191 192

    // Check that the correct hooks were called.
193
    $this->assertFileHooksCalled(['validate']);
194 195 196 197

    // Reset the hook counters.
    file_test_reset();

198
    $extensions = 'foo ' . $this->imageExtension;
199
    // Now tell file_save_upload() to allow the extension of our test image.
200
    $edit = [
201
      'file_test_replace' => FileSystemInterface::EXISTS_REPLACE,
202
      'files[file_test_upload]' => \Drupal::service('file_system')->realpath($this->image->getFileUri()),
203
      'extensions' => $extensions,
204
    ];
205

206
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
207 208 209
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
    $this->assertNoRaw(t('Only files with the following extensions are allowed:'), 'Can upload an allowed extension.');
    $this->assertRaw(t('You WIN!'), 'Found the success message.');
210 211

    // Check that the correct hooks were called.
212
    $this->assertFileHooksCalled(['validate', 'load', 'update']);
213 214 215 216 217

    // Reset the hook counters.
    file_test_reset();

    // Now tell file_save_upload() to allow any extension.
218
    $edit = [
219
      'file_test_replace' => FileSystemInterface::EXISTS_REPLACE,
220
      'files[file_test_upload]' => \Drupal::service('file_system')->realpath($this->image->getFileUri()),
221
      'allow_all_extensions' => TRUE,
222
    ];
223
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
224 225 226
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
    $this->assertNoRaw(t('Only files with the following extensions are allowed:'), 'Can upload any extension.');
    $this->assertRaw(t('You WIN!'), 'Found the success message.');
227 228

    // Check that the correct hooks were called.
229
    $this->assertFileHooksCalled(['validate', 'load', 'update']);
230 231 232 233 234
  }

  /**
   * Test dangerous file handling.
   */
235
  public function testHandleDangerousFile() {
236
    $config = $this->config('system.file');
237 238
    // Allow the .php extension and make sure it gets renamed to .txt for
    // safety. Also check to make sure its MIME type was changed.
239
    $edit = [
240
      'file_test_replace' => FileSystemInterface::EXISTS_REPLACE,
241
      'files[file_test_upload]' => \Drupal::service('file_system')->realpath($this->phpfile->uri),
242 243
      'is_image_file' => FALSE,
      'extensions' => 'php',
244
    ];
245

246
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
247
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
248
    $message = t('For security reasons, your upload has been renamed to') . ' <em class="placeholder">' . $this->phpfile->filename . '.txt' . '</em>';
249
    $this->assertRaw($message, 'Dangerous file was renamed.');
250
    $this->assertSession()->pageTextContains('File name is php-2.php.txt.');
251 252
    $this->assertRaw(t('File MIME type is text/plain.'), "Dangerous file's MIME type was changed.");
    $this->assertRaw(t('You WIN!'), 'Found the success message.');
253 254

    // Check that the correct hooks were called.
255
    $this->assertFileHooksCalled(['validate', 'insert']);
256 257 258

    // Ensure dangerous files are not renamed when insecure uploads is TRUE.
    // Turn on insecure uploads.
259
    $config->set('allow_insecure_uploads', 1)->save();
260 261 262
    // Reset the hook counters.
    file_test_reset();

263
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
264 265
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
    $this->assertNoRaw(t('For security reasons, your upload has been renamed'), 'Found no security message.');
266
    $this->assertSession()->pageTextContains('File name is php-2.php.');
267
    $this->assertRaw(t('You WIN!'), 'Found the success message.');
268 269

    // Check that the correct hooks were called.
270
    $this->assertFileHooksCalled(['validate', 'insert']);
271 272

    // Turn off insecure uploads.
273
    $config->set('allow_insecure_uploads', 0)->save();
274 275 276 277 278
  }

  /**
   * Test file munge handling.
   */
279
  public function testHandleFileMunge() {
280
    // Ensure insecure uploads are disabled for this test.
281
    $this->config('system.file')->set('allow_insecure_uploads', 0)->save();
282
    $this->image = file_move($this->image, $this->image->getFileUri() . '.foo.' . $this->imageExtension);
283 284 285 286

    // Reset the hook counters to get rid of the 'move' we just called.
    file_test_reset();

287
    $extensions = $this->imageExtension;
288
    $edit = [
289
      'files[file_test_upload]' => \Drupal::service('file_system')->realpath($this->image->getFileUri()),
290
      'extensions' => $extensions,
291
    ];
292

293
    $munged_filename = $this->image->getFilename();
294
    $munged_filename = substr($munged_filename, 0, strrpos($munged_filename, '.'));
295
    $munged_filename .= '_.' . $this->imageExtension;
296

297
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
298 299
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
    $this->assertRaw(t('For security reasons, your upload has been renamed'), 'Found security message.');
300
    $this->assertRaw(t('File name is @filename', ['@filename' => $munged_filename]), 'File was successfully munged.');
301
    $this->assertRaw(t('You WIN!'), 'Found the success message.');
302 303

    // Check that the correct hooks were called.
304
    $this->assertFileHooksCalled(['validate', 'insert']);
305 306 307 308 309

    // Ensure we don't munge files if we're allowing any extension.
    // Reset the hook counters.
    file_test_reset();

310
    $edit = [
311
      'files[file_test_upload]' => \Drupal::service('file_system')->realpath($this->image->getFileUri()),
312
      'allow_all_extensions' => TRUE,
313
    ];
314

315
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
316 317
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
    $this->assertNoRaw(t('For security reasons, your upload has been renamed'), 'Found no security message.');
318
    $this->assertRaw(t('File name is @filename', ['@filename' => $this->image->getFilename()]), 'File was not munged when allowing any extension.');
319
    $this->assertRaw(t('You WIN!'), 'Found the success message.');
320 321

    // Check that the correct hooks were called.
322
    $this->assertFileHooksCalled(['validate', 'insert']);
323 324 325 326 327
  }

  /**
   * Test renaming when uploading over a file that already exists.
   */
328
  public function testExistingRename() {
329
    $edit = [
330
      'file_test_replace' => FileSystemInterface::EXISTS_RENAME,
331
      'files[file_test_upload]' => \Drupal::service('file_system')->realpath($this->image->getFileUri()),
332
    ];
333
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
334 335
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
    $this->assertRaw(t('You WIN!'), 'Found the success message.');
336
    $this->assertSession()->pageTextContains('File name is image-test_0.png.');
337 338

    // Check that the correct hooks were called.
339
    $this->assertFileHooksCalled(['validate', 'insert']);
340 341 342 343 344
  }

  /**
   * Test replacement when uploading over a file that already exists.
   */
345
  public function testExistingReplace() {
346
    $edit = [
347
      'file_test_replace' => FileSystemInterface::EXISTS_REPLACE,
348
      'files[file_test_upload]' => \Drupal::service('file_system')->realpath($this->image->getFileUri()),
349
    ];
350
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
351 352
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
    $this->assertRaw(t('You WIN!'), 'Found the success message.');
353
    $this->assertSession()->pageTextContains('File name is image-test.png.');
354 355

    // Check that the correct hooks were called.
356
    $this->assertFileHooksCalled(['validate', 'load', 'update']);
357 358 359 360 361
  }

  /**
   * Test for failure when uploading over a file that already exists.
   */
362
  public function testExistingError() {
363
    $edit = [
364
      'file_test_replace' => FileSystemInterface::EXISTS_ERROR,
365
      'files[file_test_upload]' => \Drupal::service('file_system')->realpath($this->image->getFileUri()),
366
    ];
367
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
368 369
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
    $this->assertRaw(t('Epic upload FAIL!'), 'Found the failure message.');
370 371

    // Check that the no hooks were called while failing.
372
    $this->assertFileHooksCalled([]);
373 374 375 376 377
  }

  /**
   * Test for no failures when not uploading a file.
   */
378
  public function testNoUpload() {
379
    $this->drupalPostForm('file-test/upload', [], t('Submit'));
380
    $this->assertNoRaw(t('Epic upload FAIL!'), 'Failure message not found.');
381
  }
382 383 384 385

  /**
   * Tests for log entry on failing destination.
   */
386
  public function testDrupalMovingUploadedFileError() {
387 388
    // Create a directory and make it not writable.
    $test_directory = 'test_drupal_move_uploaded_file_fail';
389 390 391
    /** @var \Drupal\Core\File\FileSystemInterface $file_system */
    $file_system = \Drupal::service('file_system');
    $file_system->mkdir('temporary://' . $test_directory, 0000);
392 393
    $this->assertTrue(is_dir('temporary://' . $test_directory));

394
    $edit = [
395
      'file_subdir' => $test_directory,
396
      'files[file_test_upload]' => $file_system->realpath($this->image->getFileUri()),
397
    ];
398 399 400 401 402 403 404 405 406 407

    \Drupal::state()->set('file_test.disable_error_collection', TRUE);
    $this->drupalPostForm('file-test/upload', $edit, t('Submit'));
    $this->assertResponse(200, 'Received a 200 response for posted test file.');
    $this->assertRaw(t('File upload error. Could not move uploaded file.'), 'Found the failure message.');
    $this->assertRaw(t('Epic upload FAIL!'), 'Found the failure message.');

    // Uploading failed. Now check the log.
    $this->drupalGet('admin/reports/dblog');
    $this->assertResponse(200);
408
    $this->assertRaw(t('Upload error. Could not move uploaded file @file to destination @destination.', [
409
      '@file' => $this->image->getFilename(),
410
      '@destination' => 'temporary://' . $test_directory . '/' . $this->image->getFilename(),
411
    ]), 'Found upload error log entry.');
412
  }
413

414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464
  /**
   * Tests that filenames containing invalid UTF-8 are rejected.
   */
  public function testInvalidUtf8FilenameUpload() {
    $this->drupalGet('file-test/upload');

    // Filename containing invalid UTF-8.
    $filename = "x\xc0xx.gif";

    $page = $this->getSession()->getPage();
    $data = [
      'multipart' => [
        [
          'name'     => 'file_test_replace',
          'contents' => FileSystemInterface::EXISTS_RENAME,
        ],
        [
          'name' => 'form_id',
          'contents' => '_file_test_form',
        ],
        [
          'name' => 'form_build_id',
          'contents' => $page->find('hidden_field_selector', ['hidden_field', 'form_build_id'])->getAttribute('value'),
        ],
        [
          'name' => 'form_token',
          'contents' => $page->find('hidden_field_selector', ['hidden_field', 'form_token'])->getAttribute('value'),
        ],
        [
          'name' => 'op',
          'contents' => 'Submit',
        ],
        [
          'name'     => 'files[file_test_upload]',
          'contents' => 'Test content',
          'filename' => $filename,
        ],
      ],
      'cookies' => $this->getSessionCookies(),
      'http_errors' => FALSE,
    ];

    $this->assertFileNotExists('temporary://' . $filename);
    // Use Guzzle's HTTP client directly so we can POST files without having to
    // write them to disk. Not all filesystem support writing files with invalid
    // UTF-8 filenames.
    $response = $this->getHttpClient()->request('POST', Url::fromUri('base:file-test/upload')->setAbsolute()->toString(), $data);

    $content = (string) $response->getBody();
    $this->htmlOutput($content);
    $error_text = new FormattableMarkup('The file %filename could not be uploaded because the name is invalid.', ['%filename' => $filename]);
465 466
    $this->assertStringContainsString((string) $error_text, $content);
    $this->assertStringContainsString('Epic upload FAIL!', $content);
467 468 469
    $this->assertFileNotExists('temporary://' . $filename);
  }

470
}