ZIP Exports: Added ID checks and testing to validator

This commit is contained in:
Dan Brown 2024-11-18 15:53:21 +00:00
parent c2c64e207f
commit e2f6e50df4
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
10 changed files with 130 additions and 6 deletions

View File

@ -43,7 +43,7 @@ class ZipExportAttachment extends ZipExportModel
public static function validate(ZipValidationHelper $context, array $data): array
{
$rules = [
'id' => ['nullable', 'int'],
'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')],
'name' => ['required', 'string', 'min:1'],
'link' => ['required_without:file', 'nullable', 'string'],
'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()],

View File

@ -70,7 +70,7 @@ class ZipExportBook extends ZipExportModel
public static function validate(ZipValidationHelper $context, array $data): array
{
$rules = [
'id' => ['nullable', 'int'],
'id' => ['nullable', 'int', $context->uniqueIdRule('book')],
'name' => ['required', 'string', 'min:1'],
'description_html' => ['nullable', 'string'],
'cover' => ['nullable', 'string', $context->fileReferenceRule()],

View File

@ -59,7 +59,7 @@ class ZipExportChapter extends ZipExportModel
public static function validate(ZipValidationHelper $context, array $data): array
{
$rules = [
'id' => ['nullable', 'int'],
'id' => ['nullable', 'int', $context->uniqueIdRule('chapter')],
'name' => ['required', 'string', 'min:1'],
'description_html' => ['nullable', 'string'],
'priority' => ['nullable', 'int'],

View File

@ -33,7 +33,7 @@ class ZipExportImage extends ZipExportModel
public static function validate(ZipValidationHelper $context, array $data): array
{
$rules = [
'id' => ['nullable', 'int'],
'id' => ['nullable', 'int', $context->uniqueIdRule('image')],
'name' => ['required', 'string', 'min:1'],
'file' => ['required', 'string', $context->fileReferenceRule()],
'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])],

View File

@ -68,7 +68,7 @@ class ZipExportPage extends ZipExportModel
public static function validate(ZipValidationHelper $context, array $data): array
{
$rules = [
'id' => ['nullable', 'int'],
'id' => ['nullable', 'int', $context->uniqueIdRule('page')],
'name' => ['required', 'string', 'min:1'],
'html' => ['nullable', 'string'],
'markdown' => ['nullable', 'string'],

View File

@ -4,7 +4,6 @@ namespace BookStack\Exports\ZipExports;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use ZipArchive;
class ZipFileReferenceRule implements ValidationRule
{

View File

@ -0,0 +1,26 @@
<?php
namespace BookStack\Exports\ZipExports;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class ZipUniqueIdRule implements ValidationRule
{
public function __construct(
protected ZipValidationHelper $context,
protected string $modelType,
) {
}
/**
* @inheritDoc
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($this->context->hasIdBeenUsed($this->modelType, $value)) {
$fail('validation.zip_unique')->translate(['attribute' => $attribute]);
}
}
}

View File

@ -9,6 +9,13 @@ class ZipValidationHelper
{
protected Factory $validationFactory;
/**
* Local store of validated IDs (in format "<type>:<id>". Example: "book:2")
* which we can use to check uniqueness.
* @var array<string, bool>
*/
protected array $validatedIds = [];
public function __construct(
public ZipExportReader $zipReader,
) {
@ -31,6 +38,23 @@ class ZipValidationHelper
return new ZipFileReferenceRule($this);
}
public function uniqueIdRule(string $type): ZipUniqueIdRule
{
return new ZipUniqueIdRule($this, $type);
}
public function hasIdBeenUsed(string $type, int $id): bool
{
$key = $type . ':' . $id;
if (isset($this->validatedIds[$key])) {
return true;
}
$this->validatedIds[$key] = true;
return false;
}
/**
* Validate an array of relation data arrays that are expected
* to be for the given ZipExportModel.

View File

@ -107,6 +107,7 @@ return [
'zip_file' => 'The :attribute needs to reference a file within the ZIP.',
'zip_model_expected' => 'Data object expected but ":type" found.',
'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.',
// Custom validation lines
'custom' => [

View File

@ -0,0 +1,74 @@
<?php
namespace Tests\Exports;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exports\ZipExports\ZipExportReader;
use BookStack\Exports\ZipExports\ZipExportValidator;
use BookStack\Exports\ZipExports\ZipImportRunner;
use BookStack\Uploads\Image;
use Tests\TestCase;
class ZipExportValidatorTests extends TestCase
{
protected array $filesToRemove = [];
protected function tearDown(): void
{
foreach ($this->filesToRemove as $file) {
unlink($file);
}
parent::tearDown();
}
protected function getValidatorForData(array $zipData, array $files = []): ZipExportValidator
{
$upload = ZipTestHelper::zipUploadFromData($zipData, $files);
$path = $upload->getRealPath();
$this->filesToRemove[] = $path;
$reader = new ZipExportReader($path);
return new ZipExportValidator($reader);
}
public function test_ids_have_to_be_unique()
{
$validator = $this->getValidatorForData([
'book' => [
'id' => 4,
'name' => 'My book',
'pages' => [
[
'id' => 4,
'name' => 'My page',
'markdown' => 'hello',
'attachments' => [
['id' => 4, 'name' => 'Attachment A', 'link' => 'https://example.com'],
['id' => 4, 'name' => 'Attachment B', 'link' => 'https://example.com']
],
'images' => [
['id' => 4, 'name' => 'Image A', 'type' => 'gallery', 'file' => 'cat'],
['id' => 4, 'name' => 'Image b', 'type' => 'gallery', 'file' => 'cat'],
],
],
['id' => 4, 'name' => 'My page', 'markdown' => 'hello'],
],
'chapters' => [
['id' => 4, 'name' => 'Chapter 1'],
['id' => 4, 'name' => 'Chapter 2']
]
]
], ['cat' => $this->files->testFilePath('test-image.png')]);
$results = $validator->validate();
$this->assertCount(4, $results);
$expectedMessage = 'The id must be unique for the object type within the ZIP.';
$this->assertEquals($expectedMessage, $results['book.pages.0.attachments.1.id']);
$this->assertEquals($expectedMessage, $results['book.pages.0.images.1.id']);
$this->assertEquals($expectedMessage, $results['book.pages.1.id']);
$this->assertEquals($expectedMessage, $results['book.chapters.1.id']);
}
}