diff --git a/.cspell.json b/.cspell.json index dc0c8cc..c97914b 100644 --- a/.cspell.json +++ b/.cspell.json @@ -43,9 +43,9 @@ "gnomovision", "hookspec", "icanon", - "itok", "initialisation", "initialised", + "itok", "jangregor", "jonhattan", "killall", @@ -63,6 +63,7 @@ "nohup", "normalise", "normalised", + "normalises", "noscript", "oleh", "onlyname", @@ -83,6 +84,7 @@ "prompty", "prophesize", "pushd", + "recognised", "rector", "renovatebot", "ruleset", @@ -110,7 +112,7 @@ "yoyodyne", "zizmor", "zizmorcore", - "ั‚ะตัั‚" + "\u0442\u0435\u0441\u0442" ], "ignorePaths": [ ".git/", diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af5c6c..4dc5193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,13 @@ release is dated by its Drupal.org release node. was the wrapper's absolute URL encoded into one path segment, a row that could never match. A file on a private field that moved after a title change with active updating on left one behind. +- [#3277844](https://www.drupal.org/i/3277844): Staged each upload in a + directory of its own, so two uploads of a file with the same name no longer + share a staged path and an image style preview URL. The image widget showed + the first file's thumbnail for the second. The directory is removed, once + empty, when cron deletes an upload that was never saved. A site with no staging + location configured, or with a bare scheme root such as `private://`, + stages under that root. ## 8.x-1.0-rc2 - 2026-09-07 diff --git a/filefield_paths.module b/filefield_paths.module index 43558ec..8713656 100644 --- a/filefield_paths.module +++ b/filefield_paths.module @@ -213,6 +213,12 @@ function filefield_paths_file_presave(FileInterface $file): void {// phpcs:ignor \Drupal::service(File::class)->filePresave($file); } +// @phpstan-ignore-next-line +#[LegacyHook] +function filefield_paths_file_delete(FileInterface $file): void {// phpcs:ignore Drupal.Commenting.FunctionComment.Missing, Squiz.WhiteSpace.FunctionSpacing.Before + \Drupal::service(File::class)->fileDelete($file); +} + // @phpstan-ignore-next-line #[LegacyHook] function filefield_paths_file_url_alter(string &$uri): void {// phpcs:ignore Drupal.Commenting.FunctionComment.Missing, Squiz.WhiteSpace.FunctionSpacing.Before diff --git a/src/Hook/FieldWidgetSingleElementForm.php b/src/Hook/FieldWidgetSingleElementForm.php index 2852943..c85553b 100644 --- a/src/Hook/FieldWidgetSingleElementForm.php +++ b/src/Hook/FieldWidgetSingleElementForm.php @@ -4,10 +4,12 @@ namespace Drupal\filefield_paths\Hook; +use Drupal\Component\Utility\Crypt; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Config\ImmutableConfig; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Hook\Attribute\Hook; +use Drupal\filefield_paths\StagingLocation; use Drupal\filefield_paths\Utility\FieldItem; use Symfony\Component\DependencyInjection\Attribute\AutowireServiceClosure; @@ -39,8 +41,13 @@ public function formAlter(array &$element, FormStateInterface $form_state, array $settings = $context['items']->getFieldDefinition() ->getThirdPartySettings('filefield_paths'); $temp_location = $settings['temp_location'] ?? NULL; - $element['#upload_location'] = $temp_location ?: - $this->getSettings()->get('temp_location'); + $temp_location = $temp_location ?: $this->getSettings()->get('temp_location'); + // Stage each upload in a directory of its own. Two files with the same + // name would otherwise take the same staged path in turn, and the image + // style preview URL is built from that path. A browser or a CDN then + // shows the first image in place of the second. + // See https://www.drupal.org/i/3277844. + $element['#upload_location'] = StagingLocation::directory($temp_location) . StagingLocation::PREFIX . Crypt::randomBytesBase64(8); } } diff --git a/src/Hook/File.php b/src/Hook/File.php index 622adc3..0b9d3e2 100644 --- a/src/Hook/File.php +++ b/src/Hook/File.php @@ -4,11 +4,15 @@ namespace Drupal\filefield_paths\Hook; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\File\FileSystemInterface; use Drupal\Core\Hook\Attribute\Hook; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\file\FileInterface; +use Drupal\filefield_paths\StagingLocation; /** * File relate hook implementations. @@ -17,6 +21,22 @@ final class File { use StringTranslationTrait; + /** + * Constructor. + * + * @param \Drupal\Core\File\FileSystemInterface $fileSystem + * The file system service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager. + */ + public function __construct( + private readonly FileSystemInterface $fileSystem, + private readonly ConfigFactoryInterface $configFactory, + private readonly EntityTypeManagerInterface $entityTypeManager, + ) {} + /** * Implements hook_entity_base_field_info(). */ @@ -50,4 +70,43 @@ public function filePresave(FileInterface $file): void {// phpcs:ignore Squiz.Wh } } + /** + * Implements hook_file_delete(). + */ + #[Hook('file_delete')] + public function fileDelete(FileInterface $file): void { + // Each upload is staged in a directory of its own, inside a staging + // location. When the file is deleted before it was saved to an entity, + // remove the directory too. The name alone does not prove this module + // made the directory, so it must also sit in a configured staging + // location. rmdir() fails on a directory that still holds files. + $directory = $this->fileSystem->dirname($file->getFileUri()); + foreach ($this->stagingLocations() as $location) { + if (StagingLocation::isStagingDirectory($directory, $location)) { + @$this->fileSystem->rmdir($directory); + return; + } + } + } + + /** + * Returns every staging location in use. + * + * @return string[] + * The global location, or the default when none is set, and every field + * level override. Each ends in a slash. + */ + private function stagingLocations(): array { + $locations = [ + StagingLocation::directory($this->configFactory->get('filefield_paths.settings')->get('temp_location')), + ]; + foreach ($this->entityTypeManager->getStorage('field_config')->loadMultiple() as $field) { + $override = $field->getThirdPartySetting('filefield_paths', 'temp_location'); + if (is_string($override) && $override !== '') { + $locations[] = StagingLocation::directory($override); + } + } + return array_values(array_unique($locations)); + } + } diff --git a/src/StagingLocation.php b/src/StagingLocation.php new file mode 100644 index 0000000..5f20f25 --- /dev/null +++ b/src/StagingLocation.php @@ -0,0 +1,75 @@ +realpath($test_file->getFileUri()); $this->submitForm($edit, 'Upload'); - // Ensure that the file was put into the Temporary file location. + // Ensure that the file was put into the Temporary file location. Each + // upload is staged in a directory of its own under that location. $config = $this->config('filefield_paths.settings'); - $session->responseContains(\Drupal::service('file_url_generator')->generateString(sprintf('%s/%s', $config->get('temp_location'), $test_file->getFilename()))); + $temp_location_url = \Drupal::service('file_url_generator')->generateString($config->get('temp_location')); + $session->responseMatches(sprintf('#%s/ffp-[A-Za-z0-9_-]+/%s#', preg_quote($temp_location_url, '#'), preg_quote($test_file->getFilename(), '#'))); // Save the node. $this->submitForm([], 'Save'); @@ -139,9 +141,10 @@ public function testUploadFileWithCustomTempLocation(): void { $this->submitForm($edit, 'Upload'); // Ensure that the file was put into the custom Temporary file location - // defined on the field configuration (not the global setting). - $generated_url = \Drupal::service('file_url_generator')->generateString($custom_dir . '/' . $test_file->getFilename()); - $session->responseContains($generated_url); + // defined on the field configuration (not the global setting). Each + // upload is staged in a directory of its own under that location. + $custom_dir_url = \Drupal::service('file_url_generator')->generateString($custom_dir); + $session->responseMatches(sprintf('#%s/ffp-[A-Za-z0-9_-]+/%s#', preg_quote($custom_dir_url, '#'), preg_quote($test_file->getFilename(), '#'))); // Save the node. $this->submitForm([], 'Save'); diff --git a/tests/src/Functional/FileFieldPathsMultiValueReplaceTest.php b/tests/src/Functional/FileFieldPathsMultiValueReplaceTest.php new file mode 100644 index 0000000..ab5caf3 --- /dev/null +++ b/tests/src/Functional/FileFieldPathsMultiValueReplaceTest.php @@ -0,0 +1,267 @@ + TRUE, + 'transliterate' => TRUE, + ]; + $third_party_settings['filefield_paths'] = [ + 'file_path' => [ + 'value' => '[date:custom:Y]-[date:custom:m]', + 'options' => $options, + ], + 'file_name' => [ + 'value' => '[node:title].[file:ffp-extension-original]', + 'options' => $options, + ], + 'redirect' => TRUE, + 'retroactive_update' => FALSE, + 'active_updating' => FALSE, + ]; + $storage_settings['cardinality'] = FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED; + $this->createImageField(self::FIELD, $this->contentType, $storage_settings, [], $third_party_settings); + } + + /** + * Tests that replaced images keep their order, alt text and content. + */ + public function testReplaceAllImages(): void { + // Two sets of three images. Both sets use the same file names, the way a + // camera or an export does. Every file has its own content. + $first = $this->createImages('first', 10); + $second = $this->createImages('second', 40); + + // Create the node with the first set. + $this->drupalGet('node/add/' . $this->contentType); + $alts = []; + foreach ($first as $uri) { + $alts[$this->uploadImage($uri)] = 'first ' . basename($uri); + } + $edit = ['title[0][value]' => 'Photo set']; + foreach ($alts as $delta => $alt) { + $edit[sprintf('%s[%d][alt]', self::FIELD, $delta)] = $alt; + } + $this->submitForm($edit, 'Save'); + $node = $this->drupalGetNodeByTitle('Photo set'); + $this->assertInstanceOf(NodeInterface::class, $node); + $nid = (int) $node->id(); + + $before = $this->assertImages($nid, $first, 'first'); + + // Edit the node. Remove every image, then upload the second set. + $this->drupalGet('node/' . $nid . '/edit'); + $count = count($first); + for ($i = 0; $i < $count; $i++) { + $button = $this->assertSession()->elementExists('css', sprintf('input[name^="%s_"][name$="_remove_button"]', self::FIELD)); + $this->submitForm([], $button->getAttribute('name')); + } + $this->assertSession()->elementNotExists('css', sprintf('input[name^="%s_"][name$="_remove_button"]', self::FIELD)); + $alts = []; + foreach ($second as $uri) { + $alts[$this->uploadImage($uri)] = 'second ' . basename($uri); + } + $edit = []; + foreach ($alts as $delta => $alt) { + $edit[sprintf('%s[%d][alt]', self::FIELD, $delta)] = $alt; + } + $this->submitForm($edit, 'Save'); + + $after = $this->assertImages($nid, $second, 'second'); + + // The new files did not land on top of the old ones. + $this->assertEmpty(array_intersect($before, $after), 'No file URI is shared between the old and the new set.'); + foreach ($before as $index => $uri) { + $this->assertFileExists($this->realpath($uri)); + $this->assertSame(md5_file($this->realpath($first[$index])), md5_file($this->realpath($uri)), 'The old file ' . $uri . ' is unchanged.'); + } + } + + /** + * Tests that a staged upload does not reuse the preview URL of another file. + * + * The image widget shows a thumbnail of the staged file. Its URL is the + * staged path plus an itok that is a hash of that path. The upload of a new + * file with the same name lands on the same staged path once the earlier + * file has moved out, so it gets the same URL. A browser or a CDN that + * cached the first thumbnail shows it for the second file. + */ + public function testStagedPreviewUrlIsUnique(): void { + $first = $this->createImages('first', 10); + $second = $this->createImages('second', 40); + + $this->drupalGet('node/add/' . $this->contentType); + $delta = $this->uploadImage($first[0]); + $first_preview = $this->getPreviewUrl(); + $this->submitForm([ + 'title[0][value]' => 'Preview', + sprintf('%s[%d][alt]', self::FIELD, $delta) => 'first', + ], 'Save'); + $node = $this->drupalGetNodeByTitle('Preview'); + $this->assertInstanceOf(NodeInterface::class, $node); + + $this->drupalGet('node/' . $node->id() . '/edit'); + $button = $this->assertSession()->elementExists('css', sprintf('input[name^="%s_"][name$="_remove_button"]', self::FIELD)); + $this->submitForm([], $button->getAttribute('name')); + $this->uploadImage($second[0]); + $second_preview = $this->getPreviewUrl(); + + $this->assertNotSame($first_preview, $second_preview, 'Two different files never share a preview URL.'); + } + + /** + * Creates three JPEG images with the same names and different content. + * + * @param string $set + * The directory name for the set. + * @param int $size + * The width and height of the first image. Each image is 10px larger. + * + * @return string[] + * The URIs, in upload order. + */ + private function createImages(string $set, int $size): array { + $directory = 'public://' . $set; + \Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY); + $source = NULL; + foreach ($this->drupalGetTestFiles('image') as $file) { + if (str_ends_with($file->uri, 'image-test.jpg')) { + $source = $file->uri; + } + } + $this->assertNotNull($source); + + $uris = []; + foreach (['one', 'two', 'three'] as $index => $name) { + $image = \Drupal::service('image.factory')->get($source); + $image->resize($size + $index * 10, $size + $index * 10); + $uri = $directory . '/' . $name . '.jpg'; + $this->assertTrue($image->save($uri)); + $uris[] = $uri; + } + return $uris; + } + + /** + * Uploads an image into the empty slot of the widget on the current page. + * + * @param string $uri + * The file to upload. + * + * @return int + * The delta of the slot the file went into. + */ + private function uploadImage(string $uri): int { + $input = $this->assertSession()->elementExists('css', sprintf('input[type="file"][name^="files[%s_"]', self::FIELD)); + $name = (string) $input->getAttribute('name'); + $this->assertSame(1, preg_match('/_(\d+)\]\[\]$/', $name, $matches), 'The empty slot has a delta.'); + $this->submitForm([$name => $this->realpath($uri)], 'Upload'); + return (int) $matches[1]; + } + + /** + * Returns the preview image URL of the only staged item on the current page. + */ + private function getPreviewUrl(): string { + $images = $this->getSession()->getPage()->findAll('css', 'img[data-drupal-selector$="-preview"]'); + $this->assertCount(1, $images, 'The form shows one preview thumbnail.'); + $src = reset($images)->getAttribute('src'); + $this->assertNotEmpty($src); + return $src; + } + + /** + * Asserts the node's images match the uploaded set, in order. + * + * @param int $nid + * The node ID. + * @param string[] $sources + * The uploaded files, in upload order. + * @param string $set + * The set name, used in the alt text. + * + * @return string[] + * The URIs of the field's files, in delta order. + */ + private function assertImages(int $nid, array $sources, string $set): array { + $storage = \Drupal::entityTypeManager()->getStorage('node'); + $storage->resetCache([$nid]); + $node = $storage->load($nid); + $this->assertInstanceOf(NodeInterface::class, $node); + $items = $node->get(self::FIELD)->getValue(); + $this->assertCount(count($sources), $items); + + $uris = []; + foreach ($items as $delta => $item) { + $file = File::load($item['target_id']); + $this->assertInstanceOf(File::class, $file); + $uri = $file->getFileUri(); + $uris[] = $uri; + $this->assertSame($set . ' ' . basename($sources[$delta]), $item['alt'], 'Delta ' . $delta . ' keeps its alt text.'); + $this->assertMatchesRegularExpression('#^public://' . date('Y-m') . '/Photo set(_\d+)?\.jpg$#', $uri, 'Delta ' . $delta . ' is at the configured path.'); + $this->assertFileExists($this->realpath($uri)); + $this->assertSame(md5_file($this->realpath($sources[$delta])), md5_file($this->realpath($uri)), 'Delta ' . $delta . ' has the content of ' . $sources[$delta] . '.'); + } + $this->assertCount(count($sources), array_unique($uris), 'Every item has its own file.'); + return $uris; + } + + /** + * Returns the local path of a stream wrapper URI. + */ + private function realpath(string $uri): string { + $path = \Drupal::service('file_system')->realpath($uri); + $this->assertIsString($path); + return $path; + } + +} diff --git a/tests/src/Kernel/StagingDirectoryCleanupTest.php b/tests/src/Kernel/StagingDirectoryCleanupTest.php new file mode 100644 index 0000000..a56cdab --- /dev/null +++ b/tests/src/Kernel/StagingDirectoryCleanupTest.php @@ -0,0 +1,176 @@ + + */ + protected static $modules = [ + 'system', + 'user', + 'field', + 'file', + 'entity_test', + 'filefield_paths', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installEntitySchema('user'); + $this->installEntitySchema('file'); + $this->installEntitySchema('entity_test'); + $this->installSchema('file', ['file_usage']); + $this->installConfig(['filefield_paths']); + // Pin the staging location. The install default moved to + // temporary://filefield_paths in 8.x-1.0-rc2, and these tests put their + // files under public://filefield_paths. + $this->config('filefield_paths.settings')->set('temp_location', 'public://filefield_paths')->save(); + } + + /** + * An empty staging directory is removed with its last file. + */ + public function testEmptyStagingDirectoryIsRemoved(): void { + $file = $this->createFile('public://filefield_paths/ffp-abc123/example.txt'); + + $file->delete(); + + $this->assertDirectoryDoesNotExist('public://filefield_paths/ffp-abc123'); + $this->assertDirectoryExists('public://filefield_paths'); + } + + /** + * A staging directory that still holds a file is kept. + */ + public function testStagingDirectoryWithFilesIsKept(): void { + $file = $this->createFile('public://filefield_paths/ffp-abc123/example.txt'); + $this->createFile('public://filefield_paths/ffp-abc123/other.txt'); + + $file->delete(); + + $this->assertFileExists('public://filefield_paths/ffp-abc123/other.txt'); + } + + /** + * A directory the module did not create is left alone, even when empty. + */ + public function testOtherDirectoriesAreKept(): void { + $file = $this->createFile('public://other/example.txt'); + + $file->delete(); + + $this->assertFileDoesNotExist('public://other/example.txt'); + $this->assertDirectoryExists('public://other'); + } + + /** + * A staging name outside every staging location proves nothing. + * + * Another module or a site can name a directory the same way. Only a + * directory inside a configured staging location belongs to this module. + */ + public function testStagingNameOutsideTheStagingLocationIsKept(): void { + $file = $this->createFile('public://other/ffp-abc123/example.txt'); + + $file->delete(); + + $this->assertFileDoesNotExist('public://other/ffp-abc123/example.txt'); + $this->assertDirectoryExists('public://other/ffp-abc123'); + } + + /** + * A field can stage its uploads in a location of its own. + */ + public function testFieldStagingDirectoryIsRemoved(): void { + FieldStorageConfig::create([ + 'field_name' => 'field_file', + 'entity_type' => 'entity_test', + 'type' => 'file', + ])->save(); + FieldConfig::create([ + 'field_name' => 'field_file', + 'entity_type' => 'entity_test', + 'bundle' => 'entity_test', + 'third_party_settings' => [ + 'filefield_paths' => ['temp_location' => 'public://custom-staging'], + ], + ])->save(); + $file = $this->createFile('public://custom-staging/ffp-abc123/example.txt'); + + $file->delete(); + + $this->assertDirectoryDoesNotExist('public://custom-staging/ffp-abc123'); + $this->assertDirectoryExists('public://custom-staging'); + } + + /** + * A staging directory at a bare scheme root is removed too. + * + * The settings form accepts a bare scheme root. The directory then sits + * directly under that root and must still be recognised as staging. + */ + public function testSchemeRootStagingDirectoryIsRemoved(): void { + $this->config('filefield_paths.settings')->set('temp_location', 'public://')->save(); + $file = $this->createFile('public://ffp-abc123/example.txt'); + + $file->delete(); + + $this->assertDirectoryDoesNotExist('public://ffp-abc123'); + } + + /** + * With no location configured, the temporary root is the staging location. + */ + public function testUnsetLocationFallsBackToTheTemporaryRoot(): void { + $this->config('filefield_paths.settings')->set('temp_location', '')->save(); + $file = $this->createFile('temporary://ffp-abc123/example.txt'); + + $file->delete(); + + $this->assertDirectoryDoesNotExist('temporary://ffp-abc123'); + } + + /** + * Writes a file to disk and saves a temporary file entity for it. + */ + private function createFile(string $uri): File { + $file_system = $this->container->get('file_system'); + $directory = $file_system->dirname($uri); + $file_system->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY); + file_put_contents($uri, 'contents'); + $file = File::create(['uri' => $uri]); + $file->setTemporary(); + $file->save(); + return $file; + } + +} diff --git a/tests/src/Unit/FieldWidgetSingleElementFormTest.php b/tests/src/Unit/FieldWidgetSingleElementFormTest.php index 5ee04af..b120380 100644 --- a/tests/src/Unit/FieldWidgetSingleElementFormTest.php +++ b/tests/src/Unit/FieldWidgetSingleElementFormTest.php @@ -45,7 +45,7 @@ public function testUsesFieldLevelTempLocation(): void { $element = ['#type' => 'managed_file']; $hook->formAlter($element, $this->createMock(FormStateInterface::class), ['items' => $items]); - $this->assertSame('private://custom', $element['#upload_location']); + $this->assertMatchesRegularExpression('#^private://custom/ffp-[A-Za-z0-9_-]+$#', $element['#upload_location']); } /** @@ -62,7 +62,64 @@ public function testFallsBackToGlobalTempLocation(): void { $element = ['#type' => 'managed_file']; $hook->formAlter($element, $this->createMock(FormStateInterface::class), ['items' => $items]); - $this->assertSame('temporary://filefield_paths', $element['#upload_location']); + $this->assertMatchesRegularExpression('#^temporary://filefield_paths/ffp-[A-Za-z0-9_-]+$#', $element['#upload_location']); + } + + /** + * Tests that no configured location at all stages under temporary://. + * + * Core stages an upload with no destination at the temporary scheme root. + * A site whose settings lack the value, for example after a config import + * that did not carry it, must still be able to upload. + */ + public function testFallsBackToTheTemporaryRootWhenNothingIsConfigured(): void { + $config = $this->createMock(ImmutableConfig::class); + $config->method('get')->with('temp_location')->willReturn(NULL); + $config_factory = $this->createMock(ConfigFactoryInterface::class); + $config_factory->method('get')->with('filefield_paths.settings')->willReturn($config); + $hook = new FieldWidgetSingleElementForm(static fn (): MockObject => $config_factory); + + $items = $this->buildFileFieldItemList(['enabled' => TRUE]); + $element = ['#type' => 'managed_file']; + $hook->formAlter($element, $this->createMock(FormStateInterface::class), ['items' => $items]); + + $this->assertMatchesRegularExpression('#^temporary://ffp-[A-Za-z0-9_-]+$#', $element['#upload_location']); + } + + /** + * Tests that a bare scheme root stages directly under that root. + * + * The settings form accepts "private://" with no directory. Trimming the + * slashes off it would give "private:/ffp-...", which is not a URI. + */ + public function testStagesUnderTheSchemeRoot(): void { + $hook = new FieldWidgetSingleElementForm(static fn () => throw new \LogicException('Config factory should not be called.')); + + $items = $this->buildFileFieldItemList(['enabled' => TRUE, 'temp_location' => 'private://']); + $element = ['#type' => 'managed_file']; + $hook->formAlter($element, $this->createMock(FormStateInterface::class), ['items' => $items]); + + $this->assertMatchesRegularExpression('#^private://ffp-[A-Za-z0-9_-]+$#', $element['#upload_location']); + } + + /** + * Tests that every upload is staged in a directory of its own. + * + * Two uploads of a file with the same name must never share a staged path. + * A shared path gives both files the same image style preview URL. + * + * @see https://www.drupal.org/i/3277844 + */ + public function testEachUploadGetsItsOwnDirectory(): void { + $hook = new FieldWidgetSingleElementForm(static fn () => throw new \LogicException('Config factory should not be called.')); + $items = $this->buildFileFieldItemList(['enabled' => TRUE, 'temp_location' => 'private://custom']); + + $first = ['#type' => 'managed_file']; + $hook->formAlter($first, $this->createMock(FormStateInterface::class), ['items' => $items]); + $second = ['#type' => 'managed_file']; + $hook->formAlter($second, $this->createMock(FormStateInterface::class), ['items' => $items]); + + $this->assertNotSame($first['#upload_location'], $second['#upload_location']); } /** diff --git a/tests/src/Unit/StagingLocationTest.php b/tests/src/Unit/StagingLocationTest.php new file mode 100644 index 0000000..1eb4cd2 --- /dev/null +++ b/tests/src/Unit/StagingLocationTest.php @@ -0,0 +1,80 @@ +assertSame($expected, StagingLocation::directory($location)); + } + + /** + * Provides configured values and the directory each resolves to. + * + * @return array + * The configured value and the expected directory. + */ + public static function directoryProvider(): array { + return [ + 'subdirectory' => ['temporary://filefield_paths', 'temporary://filefield_paths/'], + 'trailing slash' => ['temporary://filefield_paths/', 'temporary://filefield_paths/'], + 'nested' => ['public://custom/staging', 'public://custom/staging/'], + 'bare scheme root' => ['temporary://', 'temporary://'], + 'bare private root' => ['private://', 'private://'], + 'unset' => [NULL, 'temporary://'], + 'empty' => ['', 'temporary://'], + 'not a uri' => ['staging', 'temporary://'], + 'false' => [FALSE, 'temporary://'], + ]; + } + + /** + * Only a staging directory directly inside the location is recognised. + * + * @dataProvider isStagingDirectoryProvider + */ + #[DataProvider('isStagingDirectoryProvider')] + public function testIsStagingDirectory(string $directory, string $location, bool $expected): void { + $this->assertSame($expected, StagingLocation::isStagingDirectory($directory, $location)); + } + + /** + * Provides directories, locations and whether the directory is staging. + * + * @return array + * The directory, the location and the expected result. + */ + public static function isStagingDirectoryProvider(): array { + return [ + 'inside a subdirectory location' => ['temporary://filefield_paths/ffp-abc123', 'temporary://filefield_paths/', TRUE], + 'inside a scheme root' => ['temporary://ffp-abc123', 'temporary://', TRUE], + 'inside a private root' => ['private://ffp-abc123', 'private://', TRUE], + 'another location' => ['public://other/ffp-abc123', 'temporary://filefield_paths/', FALSE], + 'one level too deep' => ['temporary://filefield_paths/sub/ffp-abc123', 'temporary://filefield_paths/', FALSE], + 'not a staging name' => ['temporary://filefield_paths/uploads', 'temporary://filefield_paths/', FALSE], + 'the location itself' => ['temporary://filefield_paths', 'temporary://filefield_paths/', FALSE], + 'a slash in the name' => ['temporary://ffp-abc/123', 'temporary://', FALSE], + ]; + } + +}