diff --git a/core/modules/file/src/Tests/FileFieldWidgetTest.php b/core/modules/file/src/Tests/FileFieldWidgetTest.php deleted file mode 100644 index 0c18eaf80bb..00000000000 --- a/core/modules/file/src/Tests/FileFieldWidgetTest.php +++ /dev/null @@ -1,601 +0,0 @@ -drupalPlaceBlock('system_breadcrumb_block'); - } - - /** - * Modules to enable. - * - * @var array - */ - public static $modules = ['comment', 'block']; - - /** - * Creates a temporary file, for a specific user. - * - * @param string $data - * A string containing the contents of the file. - * @param \Drupal\user\UserInterface $user - * The user of the file owner. - * - * @return \Drupal\file\FileInterface - * A file object, or FALSE on error. - */ - protected function createTemporaryFile($data, UserInterface $user = NULL) { - $file = file_save_data($data, NULL, NULL); - - if ($file) { - if ($user) { - $file->setOwner($user); - } - else { - $file->setOwner($this->adminUser); - } - // Change the file status to be temporary. - $file->setTemporary(); - // Save the changes. - $file->save(); - } - - return $file; - } - - /** - * Tests upload and remove buttons for a single-valued File field. - */ - public function testSingleValuedWidget() { - $node_storage = $this->container->get('entity.manager')->getStorage('node'); - $type_name = 'article'; - $field_name = strtolower($this->randomMachineName()); - $this->createFileField($field_name, 'node', $type_name); - - $test_file = $this->getTestFile('text'); - - foreach (['nojs', 'js'] as $type) { - // Create a new node with the uploaded file and ensure it got uploaded - // successfully. - // @todo This only tests a 'nojs' submission, because drupalPostAjaxForm() - // does not yet support file uploads. - $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); - $node_storage->resetCache([$nid]); - $node = $node_storage->load($nid); - $node_file = File::load($node->{$field_name}->target_id); - $this->assertFileExists($node_file, 'New file saved to disk on node creation.'); - - // Ensure the file can be downloaded. - $this->drupalGet($node_file->createFileUrl()); - $this->assertResponse(200, 'Confirmed that the generated URL is correct by downloading the shipped file.'); - - // Ensure the edit page has a remove button instead of an upload button. - $this->drupalGet("node/$nid/edit"); - $this->assertNoFieldByXPath('//input[@type="submit"]', t('Upload'), 'Node with file does not display the "Upload" button.'); - $this->assertFieldByXpath('//input[@type="submit"]', t('Remove'), 'Node with file displays the "Remove" button.'); - - // "Click" the remove button (emulating either a nojs or js submission). - switch ($type) { - case 'nojs': - $this->drupalPostForm(NULL, [], t('Remove')); - break; - case 'js': - $button = $this->xpath('//input[@type="submit" and @value="' . t('Remove') . '"]'); - $this->drupalPostAjaxForm(NULL, [], [(string) $button[0]['name'] => (string) $button[0]['value']]); - break; - } - - // Ensure the page now has an upload button instead of a remove button. - $this->assertNoFieldByXPath('//input[@type="submit"]', t('Remove'), 'After clicking the "Remove" button, it is no longer displayed.'); - $this->assertFieldByXpath('//input[@type="submit"]', t('Upload'), 'After clicking the "Remove" button, the "Upload" button is displayed.'); - // Test label has correct 'for' attribute. - $input = $this->xpath('//input[@name="files[' . $field_name . '_0]"]'); - $label = $this->xpath('//label[@for="' . (string) $input[0]['id'] . '"]'); - $this->assertTrue(isset($label[0]), 'Label for upload found.'); - - // Save the node and ensure it does not have the file. - $this->drupalPostForm(NULL, [], t('Save')); - $node_storage->resetCache([$nid]); - $node = $node_storage->load($nid); - $this->assertTrue(empty($node->{$field_name}->target_id), 'File was successfully removed from the node.'); - } - } - - /** - * Tests upload and remove buttons for multiple multi-valued File fields. - */ - public function testMultiValuedWidget() { - $node_storage = $this->container->get('entity.manager')->getStorage('node'); - $type_name = 'article'; - // Use explicit names instead of random names for those fields, because of a - // bug in drupalPostForm() with multiple file uploads in one form, where the - // order of uploads depends on the order in which the upload elements are - // added to the $form (which, in the current implementation of - // FileStorage::listAll(), comes down to the alphabetical order on field - // names). - $field_name = 'test_file_field_1'; - $field_name2 = 'test_file_field_2'; - $cardinality = 3; - $this->createFileField($field_name, 'node', $type_name, ['cardinality' => $cardinality]); - $this->createFileField($field_name2, 'node', $type_name, ['cardinality' => $cardinality]); - - $test_file = $this->getTestFile('text'); - - foreach (['nojs', 'js'] as $type) { - // Visit the node creation form, and upload 3 files for each field. Since - // the field has cardinality of 3, ensure the "Upload" button is displayed - // until after the 3rd file, and after that, isn't displayed. Because - // SimpleTest triggers the last button with a given name, so upload to the - // second field first. - // @todo This is only testing a non-Ajax upload, because drupalPostAjaxForm() - // does not yet emulate jQuery's file upload. - // - $this->drupalGet("node/add/$type_name"); - foreach ([$field_name2, $field_name] as $each_field_name) { - for ($delta = 0; $delta < 3; $delta++) { - $edit = ['files[' . $each_field_name . '_' . $delta . '][]' => \Drupal::service('file_system')->realpath($test_file->getFileUri())]; - // If the Upload button doesn't exist, drupalPostForm() will automatically - // fail with an assertion message. - $this->drupalPostForm(NULL, $edit, t('Upload')); - } - } - $this->assertNoFieldByXpath('//input[@type="submit"]', t('Upload'), 'After uploading 3 files for each field, the "Upload" button is no longer displayed.'); - - $num_expected_remove_buttons = 6; - - foreach ([$field_name, $field_name2] as $current_field_name) { - // How many uploaded files for the current field are remaining. - $remaining = 3; - // Test clicking each "Remove" button. For extra robustness, test them out - // of sequential order. They are 0-indexed, and get renumbered after each - // iteration, so array(1, 1, 0) means: - // - First remove the 2nd file. - // - Then remove what is then the 2nd file (was originally the 3rd file). - // - Then remove the first file. - foreach ([1, 1, 0] as $delta) { - // Ensure we have the expected number of Remove buttons, and that they - // are numbered sequentially. - $buttons = $this->xpath('//input[@type="submit" and @value="Remove"]'); - $this->assertTrue(is_array($buttons) && count($buttons) === $num_expected_remove_buttons, format_string('There are %n "Remove" buttons displayed (JSMode=%type).', ['%n' => $num_expected_remove_buttons, '%type' => $type])); - foreach ($buttons as $i => $button) { - $key = $i >= $remaining ? $i - $remaining : $i; - $check_field_name = $field_name2; - if ($current_field_name == $field_name && $i < $remaining) { - $check_field_name = $field_name; - } - - $this->assertIdentical((string) $button['name'], $check_field_name . '_' . $key . '_remove_button'); - } - - // "Click" the remove button (emulating either a nojs or js submission). - $button_name = $current_field_name . '_' . $delta . '_remove_button'; - switch ($type) { - case 'nojs': - // drupalPostForm() takes a $submit parameter that is the value of the - // button whose click we want to emulate. Since we have multiple - // buttons with the value "Remove", and want to control which one we - // use, we change the value of the other ones to something else. - // Since non-clicked buttons aren't included in the submitted POST - // data, and since drupalPostForm() will result in $this being updated - // with a newly rebuilt form, this doesn't cause problems. - foreach ($buttons as $button) { - if ($button['name'] != $button_name) { - $button['value'] = 'DUMMY'; - } - } - $this->drupalPostForm(NULL, [], t('Remove')); - break; - case 'js': - // drupalPostAjaxForm() lets us target the button precisely, so we don't - // require the workaround used above for nojs. - $this->drupalPostAjaxForm(NULL, [], [$button_name => t('Remove')]); - break; - } - $num_expected_remove_buttons--; - $remaining--; - - // Ensure an "Upload" button for the current field is displayed with the - // correct name. - $upload_button_name = $current_field_name . '_' . $remaining . '_upload_button'; - $buttons = $this->xpath('//input[@type="submit" and @value="Upload" and @name=:name]', [':name' => $upload_button_name]); - $this->assertTrue(is_array($buttons) && count($buttons) == 1, format_string('The upload button is displayed with the correct name (JSMode=%type).', ['%type' => $type])); - - // Ensure only at most one button per field is displayed. - $buttons = $this->xpath('//input[@type="submit" and @value="Upload"]'); - $expected = $current_field_name == $field_name ? 1 : 2; - $this->assertTrue(is_array($buttons) && count($buttons) == $expected, format_string('After removing a file, only one "Upload" button for each possible field is displayed (JSMode=%type).', ['%type' => $type])); - } - } - - // Ensure the page now has no Remove buttons. - $this->assertNoFieldByXPath('//input[@type="submit"]', t('Remove'), format_string('After removing all files, there is no "Remove" button displayed (JSMode=%type).', ['%type' => $type])); - - // Save the node and ensure it does not have any files. - $this->drupalPostForm(NULL, ['title[0][value]' => $this->randomMachineName()], t('Save')); - preg_match('/node\/([0-9]+)/', $this->getUrl(), $matches); - $nid = $matches[1]; - $node_storage->resetCache([$nid]); - $node = $node_storage->load($nid); - $this->assertTrue(empty($node->{$field_name}->target_id), 'Node was successfully saved without any files.'); - } - - $upload_files_node_creation = [$test_file, $test_file]; - // Try to upload multiple files, but fewer than the maximum. - $nid = $this->uploadNodeFiles($upload_files_node_creation, $field_name, $type_name); - $node_storage->resetCache([$nid]); - $node = $node_storage->load($nid); - $this->assertEqual(count($node->{$field_name}), count($upload_files_node_creation), 'Node was successfully saved with mulitple files.'); - - // Try to upload more files than allowed on revision. - $upload_files_node_revision = [$test_file, $test_file, $test_file, $test_file]; - $this->uploadNodeFiles($upload_files_node_revision, $field_name, $nid, 1); - $args = [ - '%field' => $field_name, - '@max' => $cardinality, - '@count' => count($upload_files_node_creation) + count($upload_files_node_revision), - '%list' => implode(', ', ['text-0_2.txt', 'text-0_3.txt', 'text-0_4.txt']), - ]; - $this->assertRaw(t('Field %field can only hold @max values but there were @count uploaded. The following files have been omitted as a result: %list.', $args)); - $node_storage->resetCache([$nid]); - $node = $node_storage->load($nid); - $this->assertEqual(count($node->{$field_name}), $cardinality, 'More files than allowed could not be saved to node.'); - - // Try to upload exactly the allowed number of files on revision. Create an - // empty node first, to fill it in its first revision. - $node = $this->drupalCreateNode([ - 'type' => $type_name, - ]); - $this->uploadNodeFile($test_file, $field_name, $node->id(), 1); - $node_storage->resetCache([$nid]); - $node = $node_storage->load($nid); - $this->assertEqual(count($node->{$field_name}), $cardinality, 'Node was successfully revised to maximum number of files.'); - - // Try to upload exactly the allowed number of files, new node. - $upload_files = array_fill(0, $cardinality, $test_file); - $nid = $this->uploadNodeFiles($upload_files, $field_name, $type_name); - $node_storage->resetCache([$nid]); - $node = $node_storage->load($nid); - $this->assertEqual(count($node->{$field_name}), $cardinality, 'Node was successfully saved with maximum number of files.'); - - // Try to upload more files than allowed, new node. - $upload_files[] = $test_file; - $this->uploadNodeFiles($upload_files, $field_name, $type_name); - - $args = [ - '%field' => $field_name, - '@max' => $cardinality, - '@count' => count($upload_files), - '%list' => 'text-0_12.txt', - ]; - $this->assertRaw(t('Field %field can only hold @max values but there were @count uploaded. The following files have been omitted as a result: %list.', $args)); - } - - /** - * Tests a file field with a "Private files" upload destination setting. - */ - public function testPrivateFileSetting() { - $node_storage = $this->container->get('entity.manager')->getStorage('node'); - // Grant the admin user required permissions. - user_role_grant_permissions($this->adminUser->roles[0]->target_id, ['administer node fields']); - - $type_name = 'article'; - $field_name = strtolower($this->randomMachineName()); - $this->createFileField($field_name, 'node', $type_name); - $field = FieldConfig::loadByName('node', $type_name, $field_name); - $field_id = $field->id(); - - $test_file = $this->getTestFile('text'); - - // Change the field setting to make its files private, and upload a file. - $edit = ['settings[uri_scheme]' => 'private']; - $this->drupalPostForm("admin/structure/types/manage/$type_name/fields/$field_id/storage", $edit, t('Save field settings')); - $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); - $node_storage->resetCache([$nid]); - $node = $node_storage->load($nid); - $node_file = File::load($node->{$field_name}->target_id); - $this->assertFileExists($node_file, 'New file saved to disk on node creation.'); - - // Ensure the private file is available to the user who uploaded it. - $this->drupalGet($node_file->createFileUrl()); - $this->assertResponse(200, 'Confirmed that the generated URL is correct by downloading the shipped file.'); - - // Ensure we can't change 'uri_scheme' field settings while there are some - // entities with uploaded files. - $this->drupalGet("admin/structure/types/manage/$type_name/fields/$field_id/storage"); - $this->assertFieldByXpath('//input[@id="edit-settings-uri-scheme-public" and @disabled="disabled"]', 'public', 'Upload destination setting disabled.'); - - // Delete node and confirm that setting could be changed. - $node->delete(); - $this->drupalGet("admin/structure/types/manage/$type_name/fields/$field_id/storage"); - $this->assertFieldByXpath('//input[@id="edit-settings-uri-scheme-public" and not(@disabled)]', 'public', 'Upload destination setting enabled.'); - } - - /** - * Tests that download restrictions on private files work on comments. - */ - public function testPrivateFileComment() { - $user = $this->drupalCreateUser(['access comments']); - - // Grant the admin user required comment permissions. - $roles = $this->adminUser->getRoles(); - user_role_grant_permissions($roles[1], ['administer comment fields', 'administer comments']); - - // Revoke access comments permission from anon user, grant post to - // authenticated. - user_role_revoke_permissions(RoleInterface::ANONYMOUS_ID, ['access comments']); - user_role_grant_permissions(RoleInterface::AUTHENTICATED_ID, ['post comments', 'skip comment approval']); - - // Create a new field. - $this->addDefaultCommentField('node', 'article'); - - $name = strtolower($this->randomMachineName()); - $label = $this->randomMachineName(); - $storage_edit = ['settings[uri_scheme]' => 'private']; - $this->fieldUIAddNewField('admin/structure/comment/manage/comment', $name, $label, 'file', $storage_edit); - - // Manually clear cache on the tester side. - \Drupal::entityManager()->clearCachedFieldDefinitions(); - - // Create node. - $edit = [ - 'title[0][value]' => $this->randomMachineName(), - ]; - $this->drupalPostForm('node/add/article', $edit, t('Save')); - $node = $this->drupalGetNodeByTitle($edit['title[0][value]']); - - // Add a comment with a file. - $text_file = $this->getTestFile('text'); - $edit = [ - 'files[field_' . $name . '_' . 0 . ']' => \Drupal::service('file_system')->realpath($text_file->getFileUri()), - 'comment_body[0][value]' => $comment_body = $this->randomMachineName(), - ]; - $this->drupalPostForm('node/' . $node->id(), $edit, t('Save')); - - // Get the comment ID. - preg_match('/comment-([0-9]+)/', $this->getUrl(), $matches); - $cid = $matches[1]; - - // Log in as normal user. - $this->drupalLogin($user); - - $comment = Comment::load($cid); - $comment_file = $comment->{'field_' . $name}->entity; - $this->assertFileExists($comment_file, 'New file saved to disk on node creation.'); - // Test authenticated file download. - $url = $comment_file->createFileUrl(); - $this->assertNotEqual($url, NULL, 'Confirmed that the URL is valid'); - $this->drupalGet($url); - $this->assertResponse(200, 'Confirmed that the generated URL is correct by downloading the shipped file.'); - - // Test anonymous file download. - $this->drupalLogout(); - $this->drupalGet($url); - $this->assertResponse(403, 'Confirmed that access is denied for the file without the needed permission.'); - - // Unpublishes node. - $this->drupalLogin($this->adminUser); - $edit = ['status[value]' => FALSE]; - $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, t('Save')); - - // Ensures normal user can no longer download the file. - $this->drupalLogin($user); - $this->drupalGet($url); - $this->assertResponse(403, 'Confirmed that access is denied for the file without the needed permission.'); - } - - /** - * Tests validation with the Upload button. - */ - public function testWidgetValidation() { - $type_name = 'article'; - $field_name = strtolower($this->randomMachineName()); - $this->createFileField($field_name, 'node', $type_name); - $this->updateFileField($field_name, $type_name, ['file_extensions' => 'txt']); - - foreach (['nojs', 'js'] as $type) { - // Create node and prepare files for upload. - $node = $this->drupalCreateNode(['type' => 'article']); - $nid = $node->id(); - $this->drupalGet("node/$nid/edit"); - $test_file_text = $this->getTestFile('text'); - $test_file_image = $this->getTestFile('image'); - $name = 'files[' . $field_name . '_0]'; - - // Upload file with incorrect extension, check for validation error. - $edit[$name] = \Drupal::service('file_system')->realpath($test_file_image->getFileUri()); - switch ($type) { - case 'nojs': - $this->drupalPostForm(NULL, $edit, t('Upload')); - break; - case 'js': - $button = $this->xpath('//input[@type="submit" and @value="' . t('Upload') . '"]'); - $this->drupalPostAjaxForm(NULL, $edit, [(string) $button[0]['name'] => (string) $button[0]['value']]); - break; - } - $error_message = t('Only files with the following extensions are allowed: %files-allowed.', ['%files-allowed' => 'txt']); - $this->assertRaw($error_message, t('Validation error when file with wrong extension uploaded (JSMode=%type).', ['%type' => $type])); - - // Upload file with correct extension, check that error message is removed. - $edit[$name] = \Drupal::service('file_system')->realpath($test_file_text->getFileUri()); - switch ($type) { - case 'nojs': - $this->drupalPostForm(NULL, $edit, t('Upload')); - break; - case 'js': - $button = $this->xpath('//input[@type="submit" and @value="' . t('Upload') . '"]'); - $this->drupalPostAjaxForm(NULL, $edit, [(string) $button[0]['name'] => (string) $button[0]['value']]); - break; - } - $this->assertNoRaw($error_message, t('Validation error removed when file with correct extension uploaded (JSMode=%type).', ['%type' => $type])); - } - } - - /** - * Tests file widget element. - */ - public function testWidgetElement() { - $field_name = mb_strtolower($this->randomMachineName()); - $html_name = str_replace('_', '-', $field_name); - $this->createFileField($field_name, 'node', 'article', ['cardinality' => FieldStorageConfig::CARDINALITY_UNLIMITED]); - $file = $this->getTestFile('text'); - $xpath = "//details[@data-drupal-selector='edit-$html_name']/div[@class='details-wrapper']/table"; - - $this->drupalGet('node/add/article'); - - $elements = $this->xpath($xpath); - - // If the field has no item, the table should not be visible. - $this->assertIdentical(count($elements), 0); - - // Upload a file. - $edit['files[' . $field_name . '_0][]'] = $this->container->get('file_system')->realpath($file->getFileUri()); - $this->drupalPostAjaxForm(NULL, $edit, "{$field_name}_0_upload_button"); - - $elements = $this->xpath($xpath); - - // If the field has at least a item, the table should be visible. - $this->assertIdentical(count($elements), 1); - - // Test for AJAX error when using progress bar on file field widget - $key = $this->randomMachineName(); - $this->drupalPost('file/progress/' . $key, 'application/json', []); - $this->assertNoResponse(500, t('No AJAX error when using progress bar on file field widget')); - $this->assertText('Starting upload...'); - } - - /** - * Tests exploiting the temporary file removal of another user using fid. - */ - public function testTemporaryFileRemovalExploit() { - // Create a victim user. - $victim_user = $this->drupalCreateUser(); - - // Create an attacker user. - $attacker_user = $this->drupalCreateUser([ - 'access content', - 'create article content', - 'edit any article content', - ]); - - // Log in as the attacker user. - $this->drupalLogin($attacker_user); - - // Perform tests using the newly created users. - $this->doTestTemporaryFileRemovalExploit($victim_user, $attacker_user); - } - - /** - * Tests exploiting the temporary file removal for anonymous users using fid. - */ - public function testTemporaryFileRemovalExploitAnonymous() { - // Set up an anonymous victim user. - $victim_user = User::getAnonymousUser(); - - // Set up an anonymous attacker user. - $attacker_user = User::getAnonymousUser(); - - // Set up permissions for anonymous attacker user. - user_role_change_permissions(RoleInterface::ANONYMOUS_ID, [ - 'access content' => TRUE, - 'create article content' => TRUE, - 'edit any article content' => TRUE, - ]); - - // Log out so as to be the anonymous attacker user. - $this->drupalLogout(); - - // Perform tests using the newly set up anonymous users. - $this->doTestTemporaryFileRemovalExploit($victim_user, $attacker_user); - } - - /** - * Helper for testing exploiting the temporary file removal using fid. - * - * @param \Drupal\user\UserInterface $victim_user - * The victim user. - * @param \Drupal\user\UserInterface $attacker_user - * The attacker user. - */ - protected function doTestTemporaryFileRemovalExploit(UserInterface $victim_user, UserInterface $attacker_user) { - $type_name = 'article'; - $field_name = 'test_file_field'; - $this->createFileField($field_name, 'node', $type_name); - - $test_file = $this->getTestFile('text'); - foreach (['nojs', 'js'] as $type) { - // Create a temporary file owned by the victim user. This will be as if - // they had uploaded the file, but not saved the node they were editing - // or creating. - $victim_tmp_file = $this->createTemporaryFile('some text', $victim_user); - $victim_tmp_file = File::load($victim_tmp_file->id()); - $this->assertTrue($victim_tmp_file->isTemporary(), 'New file saved to disk is temporary.'); - $this->assertFalse(empty($victim_tmp_file->id()), 'New file has an fid.'); - $this->assertEqual($victim_user->id(), $victim_tmp_file->getOwnerId(), 'New file belongs to the victim.'); - - // Have attacker create a new node with a different uploaded file and - // ensure it got uploaded successfully. - $edit = [ - 'title[0][value]' => $type . '-title' , - ]; - - // Attach a file to a node. - $edit['files[' . $field_name . '_0]'] = $this->container->get('file_system')->realpath($test_file->getFileUri()); - $this->drupalPostForm(Url::fromRoute('node.add', ['node_type' => $type_name]), $edit, t('Save')); - $node = $this->drupalGetNodeByTitle($edit['title[0][value]']); - - /** @var \Drupal\file\FileInterface $node_file */ - $node_file = File::load($node->{$field_name}->target_id); - $this->assertFileExists($node_file, 'A file was saved to disk on node creation'); - $this->assertEqual($attacker_user->id(), $node_file->getOwnerId(), 'New file belongs to the attacker.'); - - // Ensure the file can be downloaded. - $this->drupalGet($node_file->createFileUrl()); - $this->assertResponse(200, 'Confirmed that the generated URL is correct by downloading the shipped file.'); - - // "Click" the remove button (emulating either a nojs or js submission). - // In this POST request, the attacker "guesses" the fid of the victim's - // temporary file and uses that to remove this file. - $this->drupalGet($node->toUrl('edit-form')); - switch ($type) { - case 'nojs': - $this->drupalPostForm(NULL, [$field_name . '[0][fids]' => (string) $victim_tmp_file->id()], 'Remove'); - break; - - case 'js': - $this->drupalPostAjaxForm(NULL, [$field_name . '[0][fids]' => (string) $victim_tmp_file->id()], ["{$field_name}_0_remove_button" => 'Remove']); - break; - } - - // The victim's temporary file should not be removed by the attacker's - // POST request. - $this->assertFileExists($victim_tmp_file); - } - } - -} diff --git a/core/modules/file/tests/src/Functional/FileFieldTestBase.php b/core/modules/file/tests/src/Functional/FileFieldTestBase.php index 13f4d84148e..d9ac4bd0e59 100644 --- a/core/modules/file/tests/src/Functional/FileFieldTestBase.php +++ b/core/modules/file/tests/src/Functional/FileFieldTestBase.php @@ -20,11 +20,9 @@ abstract class FileFieldTestBase extends BrowserTestBase { } /** - * Modules to enable. - * - * @var array - */ - public static $modules = ['node', 'file', 'file_module_test', 'field_ui']; + * {@inheritdoc} + */ + protected static $modules = ['node', 'file', 'file_module_test', 'field_ui']; /** * An user with administration permissions. @@ -33,6 +31,9 @@ abstract class FileFieldTestBase extends BrowserTestBase { */ protected $adminUser; + /** + * {@inheritdoc} + */ protected function setUp() { parent::setUp(); $this->adminUser = $this->drupalCreateUser(['access content', 'access administration pages', 'administer site configuration', 'administer users', 'administer permissions', 'administer content types', 'administer node fields', 'administer node display', 'administer nodes', 'bypass node access']); @@ -44,6 +45,7 @@ abstract class FileFieldTestBase extends BrowserTestBase { * Retrieves a sample file of the specified type. * * @return \Drupal\file\FileInterface + * The new unsaved file entity. */ public function getTestFile($type_name, $size = NULL) { // Get a file to upload. @@ -142,25 +144,30 @@ abstract class FileFieldTestBase extends BrowserTestBase { $node = $node_storage->load($nid); $this->assertNotEqual($nid, $node->getRevisionId(), 'Node revision exists.'); } + $this->drupalGet("node/$nid/edit"); + $page = $this->getSession()->getPage(); // Attach files to the node. $field_storage = FieldStorageConfig::loadByName('node', $field_name); // File input name depends on number of files already uploaded. $field_num = count($node->{$field_name}); - $name = 'files[' . $field_name . "_$field_num]"; - if ($field_storage->getCardinality() != 1) { - $name .= '[]'; - } - foreach ($files as $file) { + foreach ($files as $i => $file) { + $delta = $field_num + $i; $file_path = $this->container->get('file_system')->realpath($file->getFileUri()); + $name = 'files[' . $field_name . '_' . $delta . ']'; + if ($field_storage->getCardinality() != 1) { + $name .= '[]'; + } if (count($files) == 1) { $edit[$name] = $file_path; } else { - $edit[$name][] = $file_path; + $page->attachFileToField($name, $file_path); + $this->drupalPostForm(NULL, [], t('Upload')); } } - $this->drupalPostForm("node/$nid/edit", $edit, t('Save')); + + $this->drupalPostForm(NULL, $edit, t('Save')); return $nid; } diff --git a/core/modules/file/tests/src/Functional/FileFieldWidgetTest.php b/core/modules/file/tests/src/Functional/FileFieldWidgetTest.php new file mode 100644 index 00000000000..ab9fdf1e353 --- /dev/null +++ b/core/modules/file/tests/src/Functional/FileFieldWidgetTest.php @@ -0,0 +1,525 @@ +drupalPlaceBlock('system_breadcrumb_block'); + } + + /** + * Creates a temporary file, for a specific user. + * + * @param string $data + * A string containing the contents of the file. + * @param \Drupal\user\UserInterface $user + * The user of the file owner. + * + * @return \Drupal\file\FileInterface + * A file object, or FALSE on error. + */ + protected function createTemporaryFile($data, UserInterface $user = NULL) { + $file = file_save_data($data, NULL, NULL); + + if ($file) { + if ($user) { + $file->setOwner($user); + } + else { + $file->setOwner($this->adminUser); + } + // Change the file status to be temporary. + $file->setTemporary(); + // Save the changes. + $file->save(); + } + + return $file; + } + + /** + * Tests upload and remove buttons for a single-valued File field. + */ + public function testSingleValuedWidget() { + $node_storage = $this->container->get('entity_type.manager')->getStorage('node'); + $type_name = 'article'; + $field_name = strtolower($this->randomMachineName()); + $this->createFileField($field_name, 'node', $type_name); + + $test_file = $this->getTestFile('text'); + + // Create a new node with the uploaded file and ensure it got uploaded + // successfully. + $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); + $node = $node_storage->loadUnchanged($nid); + $node_file = File::load($node->{$field_name}->target_id); + $this->assertFileExists($node_file, 'New file saved to disk on node creation.'); + + // Ensure the file can be downloaded. + $this->drupalGet($node_file->createFileUrl()); + $this->assertResponse(200, 'Confirmed that the generated URL is correct by downloading the shipped file.'); + + // Ensure the edit page has a remove button instead of an upload button. + $this->drupalGet("node/$nid/edit"); + $this->assertNoFieldByXPath('//input[@type="submit"]', t('Upload'), 'Node with file does not display the "Upload" button.'); + $this->assertFieldByXpath('//input[@type="submit"]', t('Remove'), 'Node with file displays the "Remove" button.'); + $this->drupalPostForm(NULL, [], t('Remove')); + + // Ensure the page now has an upload button instead of a remove button. + $this->assertNoFieldByXPath('//input[@type="submit"]', t('Remove'), 'After clicking the "Remove" button, it is no longer displayed.'); + $this->assertFieldByXpath('//input[@type="submit"]', t('Upload'), 'After clicking the "Remove" button, the "Upload" button is displayed.'); + // Test label has correct 'for' attribute. + $input = $this->xpath('//input[@name="files[' . $field_name . '_0]"]'); + $label = $this->xpath('//label[@for="' . $input[0]->getAttribute('id') . '"]'); + $this->assertTrue(isset($label[0]), 'Label for upload found.'); + + // Save the node and ensure it does not have the file. + $this->drupalPostForm(NULL, [], t('Save')); + $node = $node_storage->loadUnchanged($nid); + $this->assertTrue(empty($node->{$field_name}->target_id), 'File was successfully removed from the node.'); + } + + /** + * Tests upload and remove buttons for multiple multi-valued File fields. + */ + public function testMultiValuedWidget() { + $node_storage = $this->container->get('entity.manager')->getStorage('node'); + $type_name = 'article'; + // Use explicit names instead of random names for those fields, because of a + // bug in drupalPostForm() with multiple file uploads in one form, where the + // order of uploads depends on the order in which the upload elements are + // added to the $form (which, in the current implementation of + // FileStorage::listAll(), comes down to the alphabetical order on field + // names). + $field_name = 'test_file_field_1'; + $field_name2 = 'test_file_field_2'; + $cardinality = 3; + $this->createFileField($field_name, 'node', $type_name, ['cardinality' => $cardinality]); + $this->createFileField($field_name2, 'node', $type_name, ['cardinality' => $cardinality]); + + $test_file = $this->getTestFile('text'); + + // Visit the node creation form, and upload 3 files for each field. Since + // the field has cardinality of 3, ensure the "Upload" button is displayed + // until after the 3rd file, and after that, isn't displayed. Because + // SimpleTest triggers the last button with a given name, so upload to the + // second field first. + $this->drupalGet("node/add/$type_name"); + foreach ([$field_name2, $field_name] as $each_field_name) { + for ($delta = 0; $delta < 3; $delta++) { + $edit = ['files[' . $each_field_name . '_' . $delta . '][]' => \Drupal::service('file_system')->realpath($test_file->getFileUri())]; + // If the Upload button doesn't exist, drupalPostForm() will + // automatically fail with an assertion message. + $this->drupalPostForm(NULL, $edit, t('Upload')); + } + } + $this->assertNoFieldByXpath('//input[@type="submit"]', t('Upload'), 'After uploading 3 files for each field, the "Upload" button is no longer displayed.'); + + $num_expected_remove_buttons = 6; + + foreach ([$field_name, $field_name2] as $current_field_name) { + // How many uploaded files for the current field are remaining. + $remaining = 3; + // Test clicking each "Remove" button. For extra robustness, test them out + // of sequential order. They are 0-indexed, and get renumbered after each + // iteration, so array(1, 1, 0) means: + // - First remove the 2nd file. + // - Then remove what is then the 2nd file (was originally the 3rd file). + // - Then remove the first file. + foreach ([1, 1, 0] as $delta) { + // Ensure we have the expected number of Remove buttons, and that they + // are numbered sequentially. + $buttons = $this->xpath('//input[@type="submit" and @value="Remove"]'); + $this->assertTrue(is_array($buttons) && count($buttons) === $num_expected_remove_buttons, format_string('There are %n "Remove" buttons displayed.', ['%n' => $num_expected_remove_buttons])); + foreach ($buttons as $i => $button) { + $key = $i >= $remaining ? $i - $remaining : $i; + $check_field_name = $field_name2; + if ($current_field_name == $field_name && $i < $remaining) { + $check_field_name = $field_name; + } + + $this->assertIdentical($button->getAttribute('name'), $check_field_name . '_' . $key . '_remove_button'); + } + + // "Click" the remove button (emulating either a nojs or js submission). + $button_name = $current_field_name . '_' . $delta . '_remove_button'; + $this->getSession()->getPage()->findButton($button_name)->press(); + $num_expected_remove_buttons--; + $remaining--; + + // Ensure an "Upload" button for the current field is displayed with the + // correct name. + $upload_button_name = $current_field_name . '_' . $remaining . '_upload_button'; + $buttons = $this->xpath('//input[@type="submit" and @value="Upload" and @name=:name]', [':name' => $upload_button_name]); + $this->assertTrue(is_array($buttons) && count($buttons) == 1, 'The upload button is displayed with the correct name.'); + + // Ensure only at most one button per field is displayed. + $buttons = $this->xpath('//input[@type="submit" and @value="Upload"]'); + $expected = $current_field_name == $field_name ? 1 : 2; + $this->assertTrue(is_array($buttons) && count($buttons) == $expected, 'After removing a file, only one "Upload" button for each possible field is displayed.'); + } + } + + // Ensure the page now has no Remove buttons. + $this->assertNoFieldByXPath('//input[@type="submit"]', t('Remove'), 'After removing all files, there is no "Remove" button displayed.'); + + // Save the node and ensure it does not have any files. + $this->drupalPostForm(NULL, ['title[0][value]' => $this->randomMachineName()], t('Save')); + preg_match('/node\/([0-9])/', $this->getUrl(), $matches); + $nid = $matches[1]; + $node = $node_storage->loadUnchanged($nid); + $this->assertTrue(empty($node->{$field_name}->target_id), 'Node was successfully saved without any files.'); + + // Try to upload more files than allowed on revision. + $upload_files_node_revision = [$test_file, $test_file, $test_file, $test_file]; + foreach ($upload_files_node_revision as $i => $file) { + $edit['files[test_file_field_1_0][' . $i . ']'] = \Drupal::service('file_system')->realpath($test_file->getFileUri()); + } + + // @todo: Replace after https://www.drupal.org/project/drupal/issues/2917885 + $this->drupalGet('node/' . $node->id() . '/edit'); + $this->assertSession()->fieldExists('files[test_file_field_1_0][]'); + $submit_xpath = $this->assertSession()->buttonExists('Save')->getXpath(); + $client = $this->getSession()->getDriver()->getClient(); + $form = $client->getCrawler()->filterXPath($submit_xpath)->form(); + $client->request($form->getMethod(), $form->getUri(), $form->getPhpValues(), $edit); + + $node = $node_storage->loadUnchanged($nid); + $this->assertEqual(count($node->{$field_name}), $cardinality, 'More files than allowed could not be saved to node.'); + + $upload_files_node_creation = [$test_file, $test_file]; + // Try to upload multiple files, but fewer than the maximum. + $nid = $this->uploadNodeFiles($upload_files_node_creation, $field_name, $type_name, TRUE, []); + $node = $node_storage->loadUnchanged($nid); + $this->assertEqual(count($node->{$field_name}), count($upload_files_node_creation), 'Node was successfully saved with multiple files.'); + + // Try to upload exactly the allowed number of files on revision. + $this->uploadNodeFile($test_file, $field_name, $node->id(), 1, [], TRUE); + $node = $node_storage->loadUnchanged($nid); + $this->assertEqual(count($node->{$field_name}), $cardinality, 'Node was successfully revised to maximum number of files.'); + + // Try to upload exactly the allowed number of files, new node. + $upload_files = [$test_file, $test_file, $test_file]; + $nid = $this->uploadNodeFiles($upload_files, $field_name, $type_name, TRUE, []); + $node = $node_storage->loadUnchanged($nid); + $this->assertEqual(count($node->{$field_name}), $cardinality, 'Node was successfully saved with maximum number of files.'); + } + + /** + * Tests a file field with a "Private files" upload destination setting. + */ + public function testPrivateFileSetting() { + $node_storage = $this->container->get('entity.manager')->getStorage('node'); + // Grant the admin user required permissions. + user_role_grant_permissions($this->adminUser->roles[0]->target_id, ['administer node fields']); + + $type_name = 'article'; + $field_name = strtolower($this->randomMachineName()); + $this->createFileField($field_name, 'node', $type_name); + $field = FieldConfig::loadByName('node', $type_name, $field_name); + $field_id = $field->id(); + + $test_file = $this->getTestFile('text'); + + // Change the field setting to make its files private, and upload a file. + $edit = ['settings[uri_scheme]' => 'private']; + $this->drupalPostForm("admin/structure/types/manage/$type_name/fields/$field_id/storage", $edit, t('Save field settings')); + $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); + $node = $node_storage->loadUnchanged($nid); + $node_file = File::load($node->{$field_name}->target_id); + $this->assertFileExists($node_file, 'New file saved to disk on node creation.'); + + // Ensure the private file is available to the user who uploaded it. + $this->drupalGet($node_file->createFileUrl()); + $this->assertResponse(200, 'Confirmed that the generated URL is correct by downloading the shipped file.'); + + // Ensure we can't change 'uri_scheme' field settings while there are some + // entities with uploaded files. + $this->drupalGet("admin/structure/types/manage/$type_name/fields/$field_id/storage"); + $this->assertFieldByXpath('//input[@id="edit-settings-uri-scheme-public" and @disabled="disabled"]', 'public', 'Upload destination setting disabled.'); + + // Delete node and confirm that setting could be changed. + $node->delete(); + $this->drupalGet("admin/structure/types/manage/$type_name/fields/$field_id/storage"); + $this->assertFieldByXpath('//input[@id="edit-settings-uri-scheme-public" and not(@disabled)]', 'public', 'Upload destination setting enabled.'); + } + + /** + * Tests that download restrictions on private files work on comments. + */ + public function testPrivateFileComment() { + $user = $this->drupalCreateUser(['access comments']); + + // Grant the admin user required comment permissions. + $roles = $this->adminUser->getRoles(); + user_role_grant_permissions($roles[1], ['administer comment fields', 'administer comments']); + + // Revoke access comments permission from anon user, grant post to + // authenticated. + user_role_revoke_permissions(RoleInterface::ANONYMOUS_ID, ['access comments']); + user_role_grant_permissions(RoleInterface::AUTHENTICATED_ID, ['post comments', 'skip comment approval']); + + // Create a new field. + $this->addDefaultCommentField('node', 'article'); + + $name = strtolower($this->randomMachineName()); + $label = $this->randomMachineName(); + $storage_edit = ['settings[uri_scheme]' => 'private']; + $this->fieldUIAddNewField('admin/structure/comment/manage/comment', $name, $label, 'file', $storage_edit); + + // Manually clear cache on the tester side. + \Drupal::entityManager()->clearCachedFieldDefinitions(); + + // Create node. + $edit = [ + 'title[0][value]' => $this->randomMachineName(), + ]; + $this->drupalPostForm('node/add/article', $edit, t('Save')); + $node = $this->drupalGetNodeByTitle($edit['title[0][value]']); + + // Add a comment with a file. + $text_file = $this->getTestFile('text'); + $edit = [ + 'files[field_' . $name . '_' . 0 . ']' => \Drupal::service('file_system')->realpath($text_file->getFileUri()), + 'comment_body[0][value]' => $comment_body = $this->randomMachineName(), + ]; + $this->drupalPostForm('node/' . $node->id(), $edit, t('Save')); + + // Get the comment ID. + preg_match('/comment-([0-9]+)/', $this->getUrl(), $matches); + $cid = $matches[1]; + + // Log in as normal user. + $this->drupalLogin($user); + + $comment = Comment::load($cid); + $comment_file = $comment->{'field_' . $name}->entity; + $this->assertFileExists($comment_file, 'New file saved to disk on node creation.'); + // Test authenticated file download. + $url = $comment_file->createFileUrl(); + $this->assertNotEqual($url, NULL, 'Confirmed that the URL is valid'); + $this->drupalGet($comment_file->createFileUrl()); + $this->assertResponse(200, 'Confirmed that the generated URL is correct by downloading the shipped file.'); + + // Test anonymous file download. + $this->drupalLogout(); + $this->drupalGet($comment_file->createFileUrl()); + $this->assertResponse(403, 'Confirmed that access is denied for the file without the needed permission.'); + + // Unpublishes node. + $this->drupalLogin($this->adminUser); + $edit = ['status[value]' => FALSE]; + $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, t('Save')); + + // Ensures normal user can no longer download the file. + $this->drupalLogin($user); + $this->drupalGet($comment_file->createFileUrl()); + $this->assertResponse(403, 'Confirmed that access is denied for the file without the needed permission.'); + } + + /** + * Tests validation with the Upload button. + */ + public function testWidgetValidation() { + $type_name = 'article'; + $field_name = strtolower($this->randomMachineName()); + $this->createFileField($field_name, 'node', $type_name); + $this->updateFileField($field_name, $type_name, ['file_extensions' => 'txt']); + + $type = 'nojs'; + // Create node and prepare files for upload. + $node = $this->drupalCreateNode(['type' => 'article']); + $nid = $node->id(); + $this->drupalGet("node/$nid/edit"); + $test_file_text = $this->getTestFile('text'); + $test_file_image = $this->getTestFile('image'); + $name = 'files[' . $field_name . '_0]'; + + // Upload file with incorrect extension, check for validation error. + $edit[$name] = \Drupal::service('file_system')->realpath($test_file_image->getFileUri()); + $this->drupalPostForm(NULL, $edit, t('Upload')); + + $error_message = t('Only files with the following extensions are allowed: %files-allowed.', ['%files-allowed' => 'txt']); + $this->assertRaw($error_message, t('Validation error when file with wrong extension uploaded (JSMode=%type).', ['%type' => $type])); + + // Upload file with correct extension, check that error message is removed. + $edit[$name] = \Drupal::service('file_system')->realpath($test_file_text->getFileUri()); + $this->drupalPostForm(NULL, $edit, t('Upload')); + $this->assertNoRaw($error_message, t('Validation error removed when file with correct extension uploaded (JSMode=%type).', ['%type' => $type])); + } + + /** + * Tests file widget element. + */ + public function testWidgetElement() { + $field_name = mb_strtolower($this->randomMachineName()); + $html_name = str_replace('_', '-', $field_name); + $this->createFileField($field_name, 'node', 'article', ['cardinality' => FieldStorageConfig::CARDINALITY_UNLIMITED]); + $file = $this->getTestFile('text'); + $xpath = "//details[@data-drupal-selector='edit-$html_name']/div[@class='details-wrapper']/table"; + + $this->drupalGet('node/add/article'); + + $elements = $this->xpath($xpath); + + // If the field has no item, the table should not be visible. + $this->assertIdentical(count($elements), 0); + + // Upload a file. + $edit['files[' . $field_name . '_0][]'] = $this->container->get('file_system')->realpath($file->getFileUri()); + $this->drupalPostForm(NULL, $edit, "{$field_name}_0_upload_button"); + + $elements = $this->xpath($xpath); + + // If the field has at least a item, the table should be visible. + $this->assertIdentical(count($elements), 1); + + // Test for AJAX error when using progress bar on file field widget. + $http_client = $this->getHttpClient(); + $key = $this->randomMachineName(); + $post_request = $http_client->request('POST', $this->buildUrl('file/progress/' . $key), [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'http_errors' => FALSE, + ]); + $this->assertNotEquals(500, $post_request->getStatusCode()); + $body = Json::decode($post_request->getBody()); + $this->assertContains('Starting upload...', $body['message']); + } + + /** + * Tests exploiting the temporary file removal of another user using fid. + */ + public function testTemporaryFileRemovalExploit() { + // Create a victim user. + $victim_user = $this->drupalCreateUser(); + + // Create an attacker user. + $attacker_user = $this->drupalCreateUser([ + 'access content', + 'create article content', + 'edit any article content', + ]); + + // Log in as the attacker user. + $this->drupalLogin($attacker_user); + + // Perform tests using the newly created users. + $this->doTestTemporaryFileRemovalExploit($victim_user, $attacker_user); + } + + /** + * Tests exploiting the temporary file removal for anonymous users using fid. + */ + public function testTemporaryFileRemovalExploitAnonymous() { + // Set up an anonymous victim user. + $victim_user = User::getAnonymousUser(); + + // Set up an anonymous attacker user. + $attacker_user = User::getAnonymousUser(); + + // Set up permissions for anonymous attacker user. + user_role_change_permissions(RoleInterface::ANONYMOUS_ID, [ + 'access content' => TRUE, + 'create article content' => TRUE, + 'edit any article content' => TRUE, + ]); + + // Log out so as to be the anonymous attacker user. + $this->drupalLogout(); + + // Perform tests using the newly set up anonymous users. + $this->doTestTemporaryFileRemovalExploit($victim_user, $attacker_user); + } + + /** + * Helper for testing exploiting the temporary file removal using fid. + * + * @param \Drupal\user\UserInterface $victim_user + * The victim user. + * @param \Drupal\user\UserInterface $attacker_user + * The attacker user. + */ + protected function doTestTemporaryFileRemovalExploit(UserInterface $victim_user, UserInterface $attacker_user) { + $type_name = 'article'; + $field_name = 'test_file_field'; + $this->createFileField($field_name, 'node', $type_name); + + $test_file = $this->getTestFile('text'); + $type = 'no-js'; + // Create a temporary file owned by the victim user. This will be as if + // they had uploaded the file, but not saved the node they were editing + // or creating. + $victim_tmp_file = $this->createTemporaryFile('some text', $victim_user); + $victim_tmp_file = File::load($victim_tmp_file->id()); + $this->assertTrue($victim_tmp_file->isTemporary(), 'New file saved to disk is temporary.'); + $this->assertFalse(empty($victim_tmp_file->id()), 'New file has an fid.'); + $this->assertEqual($victim_user->id(), $victim_tmp_file->getOwnerId(), 'New file belongs to the victim.'); + + // Have attacker create a new node with a different uploaded file and + // ensure it got uploaded successfully. + $edit = [ + 'title[0][value]' => $type . '-title' , + ]; + + // Attach a file to a node. + $edit['files[' . $field_name . '_0]'] = $this->container->get('file_system')->realpath($test_file->getFileUri()); + $this->drupalPostForm(Url::fromRoute('node.add', ['node_type' => $type_name]), $edit, t('Save')); + $node = $this->drupalGetNodeByTitle($edit['title[0][value]']); + + /** @var \Drupal\file\FileInterface $node_file */ + $node_file = File::load($node->{$field_name}->target_id); + $this->assertFileExists($node_file, 'A file was saved to disk on node creation'); + $this->assertEqual($attacker_user->id(), $node_file->getOwnerId(), 'New file belongs to the attacker.'); + + // Ensure the file can be downloaded. + $this->drupalGet($node_file->createFileUrl()); + $this->assertResponse(200, 'Confirmed that the generated URL is correct by downloading the shipped file.'); + + // "Click" the remove button (emulating either a nojs or js submission). + // In this POST request, the attacker "guesses" the fid of the victim's + // temporary file and uses that to remove this file. + $this->drupalGet($node->toUrl('edit-form')); + + $file_id_field = $this->assertSession()->hiddenFieldExists($field_name . '[0][fids]'); + $file_id_field->setValue((string) $victim_tmp_file->id()); + $this->drupalPostForm(NULL, [], 'Remove'); + + // The victim's temporary file should not be removed by the attacker's + // POST request. + $this->assertFileExists($victim_tmp_file); + } + +} diff --git a/core/modules/file/tests/src/FunctionalJavascript/FileFieldWidgetTest.php b/core/modules/file/tests/src/FunctionalJavascript/FileFieldWidgetTest.php new file mode 100644 index 00000000000..689ca792cb3 --- /dev/null +++ b/core/modules/file/tests/src/FunctionalJavascript/FileFieldWidgetTest.php @@ -0,0 +1,222 @@ +adminUser = $this->drupalCreateUser(['access content', 'access administration pages', 'administer site configuration', 'administer users', 'administer permissions', 'administer content types', 'administer node fields', 'administer node display', 'administer nodes', 'bypass node access']); + $this->drupalLogin($this->adminUser); + $this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']); + } + + /** + * Tests upload and remove buttons for multiple multi-valued File fields. + */ + public function testMultiValuedWidget() { + $type_name = 'article'; + $field_name = 'test_file_field_1'; + $field_name2 = 'test_file_field_2'; + $cardinality = 3; + $this->createFileField($field_name, 'node', $type_name, ['cardinality' => $cardinality]); + $this->createFileField($field_name2, 'node', $type_name, ['cardinality' => $cardinality]); + + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + + $test_file = current($this->getTestFiles('text')); + $test_file_path = \Drupal::service('file_system') + ->realpath($test_file->uri); + + $this->drupalGet("node/add/$type_name"); + foreach ([$field_name2, $field_name] as $each_field_name) { + for ($delta = 0; $delta < 3; $delta++) { + $page->attachFileToField('files[' . $each_field_name . '_' . $delta . '][]', $test_file_path); + $this->assertNotNull($assert_session->waitForElementVisible('css', '[name="' . $each_field_name . '_' . $delta . '_remove_button"]')); + $this->assertNull($assert_session->waitForButton($each_field_name . '_' . $delta . '_upload_button')); + } + } + + $num_expected_remove_buttons = 6; + + foreach ([$field_name, $field_name2] as $current_field_name) { + // How many uploaded files for the current field are remaining. + $remaining = 3; + // Test clicking each "Remove" button. For extra robustness, test them out + // of sequential order. They are 0-indexed, and get renumbered after each + // iteration, so array(1, 1, 0) means: + // - First remove the 2nd file. + // - Then remove what is then the 2nd file (was originally the 3rd file). + // - Then remove the first file. + foreach ([1, 1, 0] as $delta) { + // Ensure we have the expected number of Remove buttons, and that they + // are numbered sequentially. + $buttons = $this->xpath('//input[@type="submit" and @value="Remove"]'); + $this->assertTrue(is_array($buttons) && count($buttons) === $num_expected_remove_buttons, format_string('There are %n "Remove" buttons displayed.', ['%n' => $num_expected_remove_buttons])); + foreach ($buttons as $i => $button) { + $key = $i >= $remaining ? $i - $remaining : $i; + $check_field_name = $field_name2; + if ($current_field_name == $field_name && $i < $remaining) { + $check_field_name = $field_name; + } + + $this->assertIdentical($button->getAttribute('name'), $check_field_name . '_' . $key . '_remove_button'); + } + + $button_name = $current_field_name . '_' . $delta . '_remove_button'; + $remove_button = $assert_session->waitForButton($button_name); + $remove_button->click(); + + $num_expected_remove_buttons--; + $remaining--; + + // Ensure an "Upload" button for the current field is displayed with the + // correct name. + $upload_button_name = $current_field_name . '_' . $remaining . '_upload_button'; + $this->assertNotNull($assert_session->waitForButton($upload_button_name)); + $buttons = $this->xpath('//input[@type="submit" and @value="Upload" and @name=:name]', [':name' => $upload_button_name]); + $this->assertTrue(is_array($buttons) && count($buttons) == 1, 'The upload button is displayed with the correct name.'); + + // Ensure only at most one button per field is displayed. + $buttons = $this->xpath('//input[@type="submit" and @value="Upload"]'); + $expected = $current_field_name == $field_name ? 1 : 2; + $this->assertTrue(is_array($buttons) && count($buttons) == $expected, 'After removing a file, only one "Upload" button for each possible field is displayed.'); + } + } + } + + /** + * Tests uploading and remove buttons for a single-valued File field. + */ + public function testSingleValuedWidget() { + $type_name = 'article'; + $field_name = 'test_file_field_1'; + $cardinality = 1; + $this->createFileField($field_name, 'node', $type_name, ['cardinality' => $cardinality]); + + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + + $test_file = current($this->getTestFiles('text')); + $test_file_path = \Drupal::service('file_system') + ->realpath($test_file->uri); + + $this->drupalGet("node/add/$type_name"); + + $page->findField('title[0][value]')->setValue($this->randomString()); + + $page->attachFileToField('files[' . $field_name . '_0]', $test_file_path); + $remove_button = $assert_session->waitForElementVisible('css', '[name="' . $field_name . '_0_remove_button"]'); + $this->assertNotNull($remove_button); + $remove_button->click(); + $upload_field = $assert_session->waitForElementVisible('css', 'input[type="file"]'); + $this->assertNotEmpty($upload_field); + $page->attachFileToField('files[' . $field_name . '_0]', $test_file_path); + $remove_button = $assert_session->waitForElementVisible('css', '[name="' . $field_name . '_0_remove_button"]'); + $this->assertNotNull($remove_button); + $page->pressButton('Save'); + $page->hasContent($test_file->name); + + // Create a new node and try to upload a file with an invalid extension. + $test_image = current($this->getTestFiles('image')); + $test_image_path = \Drupal::service('file_system') + ->realpath($test_image->uri); + + $this->drupalGet("node/add/$type_name"); + + $page->findField('title[0][value]')->setValue($this->randomString()); + $page->attachFileToField('files[' . $field_name . '_0]', $test_image_path); + $messages = $assert_session->waitForElementVisible('css', '.file-upload-js-error'); + $this->assertEquals('The selected file image-test.png cannot be uploaded. Only files with the following extensions are allowed: txt.', $messages->getText()); + // Make sure the error disappears when a valid file is uploaded. + $page->attachFileToField('files[' . $field_name . '_0]', $test_file_path); + $remove_button = $assert_session->waitForElementVisible('css', '[name="' . $field_name . '_0_remove_button"]'); + $this->assertNotEmpty($remove_button); + $this->assertEmpty($this->cssSelect('.file-upload-js-error')); + } + + /** + * Tests uploading more files then allowed at once. + */ + public function testUploadingMoreFilesThenAllowed() { + $type_name = 'article'; + $field_name = 'test_file_field_1'; + $cardinality = 2; + $this->createFileField($field_name, 'node', $type_name, ['cardinality' => $cardinality]); + + $web_driver = $this->getSession()->getDriver(); + $file_system = \Drupal::service('file_system'); + + $files = array_slice($this->getTestFiles('text'), 0, 3); + $real_paths = []; + foreach ($files as $file) { + $real_paths[] = $file_system->realpath($file->uri); + } + $remote_paths = []; + foreach ($real_paths as $path) { + $remote_paths[] = $web_driver->uploadFileAndGetRemoteFilePath($path); + } + + // Tests that uploading multiple remote files works with remote path. + $this->drupalGet("node/add/$type_name"); + $multiple_field = $this->getSession()->getPage()->findField('files[test_file_field_1_0][]'); + $multiple_field->setValue(implode("\n", $remote_paths)); + $this->assertSession()->assertWaitOnAjaxRequest(); + $args = [ + '%field' => $field_name, + '@max' => $cardinality, + '@count' => 3, + '%list' => 'text-2.txt', + ]; + $this->assertRaw(t('Field %field can only hold @max values but there were @count uploaded. The following files have been omitted as a result: %list.', $args)); + } + + /** + * Retrieves a sample file of the specified type. + * + * @return \Drupal\file\FileInterface + * The new unsaved file entity. + */ + public function getTestFile($type_name, $size = NULL) { + // Get a file to upload. + $file = current($this->getTestFiles($type_name, $size)); + + // Add a filesize property to files as would be read by + // \Drupal\file\Entity\File::load(). + $file->filesize = filesize($file->uri); + + return File::create((array) $file); + } + +}