Upload beautify
This commit is contained in:
212
tests/Feature/Uploads/ArchiveUploadSecurityTest.php
Normal file
212
tests/Feature/Uploads/ArchiveUploadSecurityTest.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/** @var array<int, string> */
|
||||
$tempArchives = [];
|
||||
|
||||
afterEach(function () use (&$tempArchives): void {
|
||||
foreach ($tempArchives as $path) {
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
$tempArchives = [];
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
* @param (callable(\ZipArchive,string):void)|null $entryCallback
|
||||
*/
|
||||
function makeArchiveUpload(array $entries, array &$tempArchives, ?callable $entryCallback = null): UploadedFile
|
||||
{
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
test()->markTestSkipped('ZipArchive extension is required.');
|
||||
}
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'sb_upload_zip_');
|
||||
if ($path === false) {
|
||||
throw new \RuntimeException('Failed to allocate temp archive path.');
|
||||
}
|
||||
|
||||
$tempArchives[] = $path;
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($path, \ZipArchive::OVERWRITE | \ZipArchive::CREATE) !== true) {
|
||||
throw new \RuntimeException('Failed to create test zip archive.');
|
||||
}
|
||||
|
||||
foreach ($entries as $name => $content) {
|
||||
$zip->addFromString($name, $content);
|
||||
if ($entryCallback !== null) {
|
||||
$entryCallback($zip, $name);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return new UploadedFile($path, 'archive.zip', 'application/zip', null, true);
|
||||
}
|
||||
|
||||
it('rejects archive with zip slip path during preload', function () use (&$tempArchives) {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
|
||||
$archive = makeArchiveUpload([
|
||||
'../evil.txt' => 'x',
|
||||
], $tempArchives);
|
||||
|
||||
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
|
||||
'main' => $archive,
|
||||
'screenshots' => [$screenshot],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('message', 'Archive inspection failed.');
|
||||
|
||||
expect(strtolower((string) $response->json('reason')))->toContain('path');
|
||||
});
|
||||
|
||||
it('rejects archive with symlink during preload', function () use (&$tempArchives) {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
|
||||
$archive = makeArchiveUpload([
|
||||
'safe/readme.txt' => 'ok',
|
||||
'safe/link' => 'target',
|
||||
], $tempArchives, function (\ZipArchive $zip, string $entryName): void {
|
||||
if ($entryName === 'safe/link') {
|
||||
$zip->setExternalAttributesName($entryName, \ZipArchive::OPSYS_UNIX, 0120777 << 16);
|
||||
}
|
||||
});
|
||||
|
||||
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
|
||||
'main' => $archive,
|
||||
'screenshots' => [$screenshot],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('message', 'Archive inspection failed.');
|
||||
|
||||
expect(strtolower((string) $response->json('reason')))->toContain('symlink');
|
||||
});
|
||||
|
||||
it('rejects archive with deep nesting during preload', function () use (&$tempArchives) {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
|
||||
$archive = makeArchiveUpload([
|
||||
'a/b/c/d/e/f/file.txt' => 'deep',
|
||||
], $tempArchives);
|
||||
|
||||
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
|
||||
'main' => $archive,
|
||||
'screenshots' => [$screenshot],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('message', 'Archive inspection failed.');
|
||||
|
||||
expect(strtolower((string) $response->json('reason')))->toContain('depth');
|
||||
});
|
||||
|
||||
it('rejects archive with too many files during preload', function () use (&$tempArchives) {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
|
||||
$entries = [];
|
||||
for ($index = 0; $index < 5001; $index++) {
|
||||
$entries['f' . $index . '.txt'] = 'x';
|
||||
}
|
||||
|
||||
$archive = makeArchiveUpload($entries, $tempArchives);
|
||||
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
|
||||
'main' => $archive,
|
||||
'screenshots' => [$screenshot],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('message', 'Archive inspection failed.');
|
||||
|
||||
expect((string) $response->json('reason'))->toContain('5000');
|
||||
});
|
||||
|
||||
it('rejects archive with executable inside during preload', function () use (&$tempArchives) {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
|
||||
$archive = makeArchiveUpload([
|
||||
'safe/readme.txt' => 'ok',
|
||||
'safe/run.exe' => 'MZ',
|
||||
], $tempArchives);
|
||||
|
||||
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
|
||||
'main' => $archive,
|
||||
'screenshots' => [$screenshot],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('message', 'Archive inspection failed.');
|
||||
|
||||
expect(strtolower((string) $response->json('reason')))->toContain('blocked');
|
||||
});
|
||||
|
||||
it('rejects archive with zip bomb ratio during preload', function () use (&$tempArchives) {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
|
||||
$archive = makeArchiveUpload([
|
||||
'payload.txt' => str_repeat('A', 6 * 1024 * 1024),
|
||||
], $tempArchives);
|
||||
|
||||
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
|
||||
'main' => $archive,
|
||||
'screenshots' => [$screenshot],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('message', 'Archive inspection failed.');
|
||||
|
||||
expect(strtolower((string) $response->json('reason')))->toContain('ratio');
|
||||
});
|
||||
|
||||
it('accepts valid archive during preload', function () use (&$tempArchives) {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
|
||||
$archive = makeArchiveUpload([
|
||||
'skins/theme/readme.txt' => 'hello',
|
||||
'skins/theme/layout.ini' => 'v=1',
|
||||
], $tempArchives);
|
||||
|
||||
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
|
||||
'main' => $archive,
|
||||
'screenshots' => [$screenshot],
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJsonStructure([
|
||||
'upload_id',
|
||||
'status',
|
||||
'expires_at',
|
||||
]);
|
||||
});
|
||||
47
tests/Feature/Uploads/PreviewGenerationJobTest.php
Normal file
47
tests/Feature/Uploads/PreviewGenerationJobTest.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Uploads\Jobs\PreviewGenerationJob;
|
||||
use App\Uploads\Jobs\TagAnalysisJob;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('PreviewGenerationJob dispatches TagAnalysisJob', function () {
|
||||
Storage::fake('local');
|
||||
Bus::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$uploadId = (string) Str::uuid();
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $uploadId,
|
||||
'user_id' => $user->id,
|
||||
'type' => 'archive',
|
||||
'status' => 'draft',
|
||||
'is_scanned' => true,
|
||||
'has_tags' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// archive path with no screenshot uses placeholder path in PreviewService
|
||||
$job = new PreviewGenerationJob($uploadId);
|
||||
$job->handle(app(\App\Services\Upload\PreviewService::class));
|
||||
|
||||
$this->assertDatabaseHas('uploads', [
|
||||
'id' => $uploadId,
|
||||
]);
|
||||
|
||||
Bus::assertDispatched(TagAnalysisJob::class, function (TagAnalysisJob $queuedJob) use ($uploadId) {
|
||||
$reflect = new ReflectionClass($queuedJob);
|
||||
$property = $reflect->getProperty('uploadId');
|
||||
$property->setAccessible(true);
|
||||
|
||||
return $property->getValue($queuedJob) === $uploadId;
|
||||
});
|
||||
});
|
||||
165
tests/Feature/Uploads/UploadAutosaveTest.php
Normal file
165
tests/Feature/Uploads/UploadAutosaveTest.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createCategoryForAutosaveTests(): int
|
||||
{
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return DB::table('categories')->insertGetId([
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => null,
|
||||
'name' => 'Nature',
|
||||
'slug' => 'nature-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function createDraftUploadForAutosave(int $userId, string $status = 'draft'): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $id,
|
||||
'user_id' => $userId,
|
||||
'type' => 'image',
|
||||
'status' => $status,
|
||||
'title' => 'Original Title',
|
||||
'description' => 'Original Description',
|
||||
'license' => 'default',
|
||||
'nsfw' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
it('owner can autosave', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$categoryId = createCategoryForAutosaveTests();
|
||||
$uploadId = createDraftUploadForAutosave($owner->id);
|
||||
|
||||
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/autosave", [
|
||||
'title' => 'Updated Title',
|
||||
'category_id' => $categoryId,
|
||||
'description' => 'Updated Description',
|
||||
'tags' => ['night', 'city'],
|
||||
'license' => 'cc-by',
|
||||
'nsfw' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJsonStructure([
|
||||
'success',
|
||||
'updated_at',
|
||||
])->assertJson([
|
||||
'success' => true,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('uploads', [
|
||||
'id' => $uploadId,
|
||||
'title' => 'Updated Title',
|
||||
'category_id' => $categoryId,
|
||||
'description' => 'Updated Description',
|
||||
'license' => 'cc-by',
|
||||
'nsfw' => 1,
|
||||
]);
|
||||
|
||||
$row = DB::table('uploads')->where('id', $uploadId)->first();
|
||||
expect(json_decode((string) $row->tags, true))->toBe(['night', 'city']);
|
||||
});
|
||||
|
||||
it('partial update works', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$uploadId = createDraftUploadForAutosave($owner->id);
|
||||
|
||||
$before = DB::table('uploads')->where('id', $uploadId)->first();
|
||||
|
||||
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/autosave", [
|
||||
'title' => 'Only Title Changed',
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJson([
|
||||
'success' => true,
|
||||
]);
|
||||
|
||||
$after = DB::table('uploads')->where('id', $uploadId)->first();
|
||||
|
||||
expect($after->title)->toBe('Only Title Changed');
|
||||
expect($after->description)->toBe($before->description);
|
||||
expect($after->license)->toBe($before->license);
|
||||
});
|
||||
|
||||
it('guest denied', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$uploadId = createDraftUploadForAutosave($owner->id);
|
||||
|
||||
$response = $this->postJson("/api/uploads/{$uploadId}/autosave", [
|
||||
'title' => 'Nope',
|
||||
]);
|
||||
|
||||
expect(in_array($response->getStatusCode(), [401, 403]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('other user denied', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$uploadId = createDraftUploadForAutosave($owner->id);
|
||||
|
||||
$response = $this->actingAs($other)->postJson("/api/uploads/{$uploadId}/autosave", [
|
||||
'title' => 'Hacker Update',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
it('published upload rejected', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$uploadId = createDraftUploadForAutosave($owner->id, 'published');
|
||||
|
||||
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/autosave", [
|
||||
'title' => 'Should Not Save',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
it('invalid category rejected', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$uploadId = createDraftUploadForAutosave($owner->id);
|
||||
|
||||
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/autosave", [
|
||||
'category_id' => 999999,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrors(['category_id']);
|
||||
});
|
||||
89
tests/Feature/Uploads/UploadCleanupCommandTest.php
Normal file
89
tests/Feature/Uploads/UploadCleanupCommandTest.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createCleanupUpload(array $overrides = []): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
|
||||
$defaults = [
|
||||
'id' => $id,
|
||||
'user_id' => User::factory()->create()->id,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'title' => null,
|
||||
'slug' => null,
|
||||
'category_id' => null,
|
||||
'description' => null,
|
||||
'tags' => null,
|
||||
'license' => null,
|
||||
'nsfw' => false,
|
||||
'is_scanned' => false,
|
||||
'has_tags' => false,
|
||||
'preview_path' => null,
|
||||
'published_at' => null,
|
||||
'final_path' => null,
|
||||
'expires_at' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
DB::table('uploads')->insert(array_merge($defaults, $overrides));
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
it('runs uploads cleanup command and deletes stale drafts', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$expiredId = createCleanupUpload([
|
||||
'status' => 'draft',
|
||||
'expires_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$activeId = createCleanupUpload([
|
||||
'status' => 'draft',
|
||||
'expires_at' => now()->addHours(2),
|
||||
'updated_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$expiredId}/meta.json", '{}');
|
||||
Storage::disk('local')->put("tmp/drafts/{$activeId}/meta.json", '{}');
|
||||
|
||||
$code = Artisan::call('uploads:cleanup');
|
||||
|
||||
expect($code)->toBe(0);
|
||||
expect(Artisan::output())->toContain('Uploads cleanup deleted 1 draft(s).');
|
||||
|
||||
expect(DB::table('uploads')->where('id', $expiredId)->exists())->toBeFalse();
|
||||
expect(DB::table('uploads')->where('id', $activeId)->where('status', 'draft')->exists())->toBeTrue();
|
||||
expect(Storage::disk('local')->exists("tmp/drafts/{$expiredId}/meta.json"))->toBeFalse();
|
||||
expect(Storage::disk('local')->exists("tmp/drafts/{$activeId}/meta.json"))->toBeTrue();
|
||||
});
|
||||
|
||||
it('respects command limit option', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
for ($index = 0; $index < 5; $index++) {
|
||||
$uploadId = createCleanupUpload([
|
||||
'status' => 'draft',
|
||||
'updated_at' => now()->subHours(26),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
|
||||
}
|
||||
|
||||
$code = Artisan::call('uploads:cleanup', ['--limit' => 2]);
|
||||
|
||||
expect($code)->toBe(0);
|
||||
expect(Artisan::output())->toContain('Uploads cleanup deleted 2 draft(s).');
|
||||
|
||||
expect(DB::table('uploads')->count())->toBe(3);
|
||||
});
|
||||
30
tests/Feature/Uploads/UploadFeatureFlagTest.php
Normal file
30
tests/Feature/Uploads/UploadFeatureFlagTest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('injects uploads v2 flag as false when disabled', function () {
|
||||
config(['features.uploads_v2' => false]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/upload');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('window.SKINBASE_FLAGS', false);
|
||||
$response->assertSee('uploads_v2: false', false);
|
||||
});
|
||||
|
||||
it('injects uploads v2 flag as true when enabled', function () {
|
||||
config(['features.uploads_v2' => true]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/upload');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('window.SKINBASE_FLAGS', false);
|
||||
$response->assertSee('uploads_v2: true', false);
|
||||
});
|
||||
191
tests/Feature/Uploads/UploadPreloadTest.php
Normal file
191
tests/Feature/Uploads/UploadPreloadTest.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Uploads\Jobs\VirusScanJob;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeValidArchiveUpload(): UploadedFile
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'sb_preload_zip_');
|
||||
if ($path === false) {
|
||||
throw new \RuntimeException('Unable to allocate temporary zip path.');
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($path, \ZipArchive::OVERWRITE | \ZipArchive::CREATE) !== true) {
|
||||
throw new \RuntimeException('Unable to create temporary zip file.');
|
||||
}
|
||||
|
||||
$zip->addFromString('skins/theme/readme.txt', 'safe');
|
||||
$zip->addFromString('skins/theme/colors.ini', 'accent=blue');
|
||||
$zip->close();
|
||||
|
||||
return new UploadedFile($path, 'pack.zip', 'application/zip', null, true);
|
||||
}
|
||||
|
||||
it('authenticated user can preload image', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
|
||||
$main = UploadedFile::fake()->image('main.jpg', 1200, 800);
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->postJson('/api/uploads/preload', [
|
||||
'main' => $main,
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJsonStructure([
|
||||
'upload_id',
|
||||
'status',
|
||||
'expires_at',
|
||||
]);
|
||||
|
||||
$uploadId = $response->json('upload_id');
|
||||
|
||||
$this->assertDatabaseHas('uploads', [
|
||||
'id' => $uploadId,
|
||||
'user_id' => $user->id,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('upload_files', [
|
||||
'upload_id' => $uploadId,
|
||||
'type' => 'main',
|
||||
]);
|
||||
|
||||
Storage::disk('local')->assertExists("tmp/drafts/{$uploadId}/meta.json");
|
||||
expect(Storage::disk('local')->allFiles("tmp/drafts/{$uploadId}"))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('authenticated user can preload archive with screenshot', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
|
||||
$main = makeValidArchiveUpload();
|
||||
$screenshot = UploadedFile::fake()->image('screen1.jpg', 800, 600);
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->postJson('/api/uploads/preload', [
|
||||
'main' => $main,
|
||||
'screenshots' => [$screenshot],
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJsonStructure([
|
||||
'upload_id',
|
||||
'status',
|
||||
'expires_at',
|
||||
]);
|
||||
|
||||
$uploadId = $response->json('upload_id');
|
||||
|
||||
$this->assertDatabaseHas('uploads', [
|
||||
'id' => $uploadId,
|
||||
'user_id' => $user->id,
|
||||
'type' => 'archive',
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('upload_files', [
|
||||
'upload_id' => $uploadId,
|
||||
'type' => 'main',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('upload_files', [
|
||||
'upload_id' => $uploadId,
|
||||
'type' => 'screenshot',
|
||||
]);
|
||||
|
||||
Storage::disk('local')->assertExists("tmp/drafts/{$uploadId}/meta.json");
|
||||
expect(Storage::disk('local')->allFiles("tmp/drafts/{$uploadId}"))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('guest is rejected', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$main = UploadedFile::fake()->image('main.jpg', 1200, 800);
|
||||
|
||||
$response = $this->postJson('/api/uploads/preload', [
|
||||
'main' => $main,
|
||||
]);
|
||||
|
||||
expect(in_array($response->getStatusCode(), [401, 403]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('missing main file fails', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->postJson('/api/uploads/preload', []);
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrors(['main']);
|
||||
});
|
||||
|
||||
it('archive without screenshot fails', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
|
||||
$main = UploadedFile::fake()->create('pack.zip', 1024, 'application/zip');
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->postJson('/api/uploads/preload', [
|
||||
'main' => $main,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrors(['screenshots']);
|
||||
});
|
||||
|
||||
it('invalid file type is rejected', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
|
||||
$main = UploadedFile::fake()->create('notes.txt', 4, 'text/plain');
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->postJson('/api/uploads/preload', [
|
||||
'main' => $main,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrors(['main']);
|
||||
});
|
||||
|
||||
it('preload dispatches VirusScanJob', function () {
|
||||
Storage::fake('local');
|
||||
Bus::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$main = UploadedFile::fake()->image('main.jpg', 1200, 800);
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->postJson('/api/uploads/preload', [
|
||||
'main' => $main,
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJsonStructure([
|
||||
'upload_id',
|
||||
'status',
|
||||
'expires_at',
|
||||
]);
|
||||
|
||||
$uploadId = $response->json('upload_id');
|
||||
|
||||
Bus::assertDispatched(VirusScanJob::class, function (VirusScanJob $job) use ($uploadId) {
|
||||
$reflect = new \ReflectionClass($job);
|
||||
$property = $reflect->getProperty('uploadId');
|
||||
$property->setAccessible(true);
|
||||
|
||||
return $property->getValue($job) === $uploadId;
|
||||
});
|
||||
});
|
||||
108
tests/Feature/Uploads/UploadProcessingLifecycleTest.php
Normal file
108
tests/Feature/Uploads/UploadProcessingLifecycleTest.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Upload\PreviewService;
|
||||
use App\Services\Upload\TagAnalysisService;
|
||||
use App\Services\Uploads\UploadScanService;
|
||||
use App\Uploads\Jobs\PreviewGenerationJob;
|
||||
use App\Uploads\Jobs\TagAnalysisJob;
|
||||
use App\Uploads\Jobs\VirusScanJob;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createUploadForLifecycle(int $userId, array $overrides = []): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
|
||||
$defaults = [
|
||||
'id' => $id,
|
||||
'user_id' => $userId,
|
||||
'type' => 'archive',
|
||||
'status' => 'draft',
|
||||
'processing_state' => 'pending_scan',
|
||||
'is_scanned' => false,
|
||||
'has_tags' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
DB::table('uploads')->insert(array_merge($defaults, $overrides));
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
it('moves through explicit processing state lifecycle', function () {
|
||||
Storage::fake('local');
|
||||
Bus::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$uploadId = createUploadForLifecycle($user->id);
|
||||
|
||||
$mainPath = "tmp/drafts/{$uploadId}/main/archive.zip";
|
||||
Storage::disk('local')->put($mainPath, 'archive-bytes');
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $uploadId,
|
||||
'path' => $mainPath,
|
||||
'type' => 'main',
|
||||
'hash' => 'aa11bb22cc33dd44',
|
||||
'size' => 100,
|
||||
'mime' => 'application/zip',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
(new VirusScanJob($uploadId))->handle(app(UploadScanService::class));
|
||||
expect(DB::table('uploads')->where('id', $uploadId)->value('processing_state'))->toBe('generating_preview');
|
||||
|
||||
(new PreviewGenerationJob($uploadId))->handle(app(PreviewService::class));
|
||||
expect(DB::table('uploads')->where('id', $uploadId)->value('processing_state'))->toBe('analyzing_tags');
|
||||
|
||||
(new TagAnalysisJob($uploadId))->handle(app(TagAnalysisService::class));
|
||||
expect(DB::table('uploads')->where('id', $uploadId)->value('processing_state'))->toBe('ready');
|
||||
expect((bool) DB::table('uploads')->where('id', $uploadId)->value('has_tags'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not regress processing state when jobs rerun', function () {
|
||||
Storage::fake('local');
|
||||
Bus::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$uploadId = (string) Str::uuid();
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $uploadId,
|
||||
'user_id' => $user->id,
|
||||
'type' => 'archive',
|
||||
'status' => 'draft',
|
||||
'processing_state' => 'ready',
|
||||
'is_scanned' => true,
|
||||
'has_tags' => true,
|
||||
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$mainPath = "tmp/drafts/{$uploadId}/main/archive.zip";
|
||||
Storage::disk('local')->put($mainPath, 'archive-bytes');
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $uploadId,
|
||||
'path' => $mainPath,
|
||||
'type' => 'main',
|
||||
'hash' => 'ee11bb22cc33dd44',
|
||||
'size' => 100,
|
||||
'mime' => 'application/zip',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
(new VirusScanJob($uploadId))->handle(app(UploadScanService::class));
|
||||
(new PreviewGenerationJob($uploadId))->handle(app(PreviewService::class));
|
||||
(new TagAnalysisJob($uploadId))->handle(app(TagAnalysisService::class));
|
||||
|
||||
expect(DB::table('uploads')->where('id', $uploadId)->value('processing_state'))->toBe('ready');
|
||||
});
|
||||
143
tests/Feature/Uploads/UploadPublishEndpointTest.php
Normal file
143
tests/Feature/Uploads/UploadPublishEndpointTest.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createCategoryForPublishEndpointTests(): int
|
||||
{
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return DB::table('categories')->insertGetId([
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => null,
|
||||
'name' => 'Street',
|
||||
'slug' => 'street-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function createReadyDraftForPublishEndpoint(int $ownerId, int $categoryId, string $status = 'draft'): array
|
||||
{
|
||||
$uploadId = (string) Str::uuid();
|
||||
$hash = 'aabbccddeeff00112233';
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $uploadId,
|
||||
'user_id' => $ownerId,
|
||||
'type' => 'image',
|
||||
'status' => $status,
|
||||
'moderation_status' => 'approved',
|
||||
'title' => 'Publish Endpoint Test',
|
||||
'category_id' => $categoryId,
|
||||
'is_scanned' => true,
|
||||
'has_tags' => true,
|
||||
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/main.jpg", 'jpg');
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview');
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $uploadId,
|
||||
'path' => "tmp/drafts/{$uploadId}/main/main.jpg",
|
||||
'type' => 'main',
|
||||
'hash' => $hash,
|
||||
'size' => 3,
|
||||
'mime' => 'image/jpeg',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return [$uploadId, $hash];
|
||||
}
|
||||
|
||||
it('owner can publish valid draft', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$categoryId = createCategoryForPublishEndpointTests();
|
||||
[$uploadId, $hash] = createReadyDraftForPublishEndpoint($owner->id, $categoryId);
|
||||
|
||||
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/publish");
|
||||
|
||||
$response->assertOk()->assertJsonStructure([
|
||||
'success',
|
||||
'upload_id',
|
||||
'status',
|
||||
'published_at',
|
||||
'final_path',
|
||||
])->assertJson([
|
||||
'success' => true,
|
||||
'upload_id' => $uploadId,
|
||||
'status' => 'published',
|
||||
'final_path' => 'files/artworks/aa/bb/' . $hash,
|
||||
]);
|
||||
});
|
||||
|
||||
it('guest denied', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$categoryId = createCategoryForPublishEndpointTests();
|
||||
[$uploadId] = createReadyDraftForPublishEndpoint($owner->id, $categoryId);
|
||||
|
||||
$response = $this->postJson("/api/uploads/{$uploadId}/publish");
|
||||
|
||||
expect(in_array($response->getStatusCode(), [401, 403]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('other user denied', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$categoryId = createCategoryForPublishEndpointTests();
|
||||
[$uploadId] = createReadyDraftForPublishEndpoint($owner->id, $categoryId);
|
||||
|
||||
$response = $this->actingAs($other)->postJson("/api/uploads/{$uploadId}/publish");
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
it('incomplete draft rejected', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$categoryId = createCategoryForPublishEndpointTests();
|
||||
[$uploadId] = createReadyDraftForPublishEndpoint($owner->id, $categoryId);
|
||||
|
||||
DB::table('uploads')->where('id', $uploadId)->update(['title' => null]);
|
||||
|
||||
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/publish");
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
it('already published rejected', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$categoryId = createCategoryForPublishEndpointTests();
|
||||
[$uploadId] = createReadyDraftForPublishEndpoint($owner->id, $categoryId, 'published');
|
||||
|
||||
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/publish");
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
264
tests/Feature/Uploads/UploadPublishTest.php
Normal file
264
tests/Feature/Uploads/UploadPublishTest.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Upload;
|
||||
use App\Models\User;
|
||||
use App\Uploads\Services\PublishService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createCategoryForUploadPublishFeatureTests(): int
|
||||
{
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return DB::table('categories')->insertGetId([
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => null,
|
||||
'name' => 'Urban',
|
||||
'slug' => 'urban-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('publishes upload and moves draft files', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$categoryId = createCategoryForUploadPublishFeatureTests();
|
||||
$uploadId = (string) Str::uuid();
|
||||
$hash = 'aabbccddeeff00112233';
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $uploadId,
|
||||
'user_id' => $user->id,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'moderation_status' => 'approved',
|
||||
'title' => 'Night City',
|
||||
'category_id' => $categoryId,
|
||||
'is_scanned' => true,
|
||||
'has_tags' => true,
|
||||
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/night-city.jpg", 'jpg-binary');
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview');
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/thumb.webp", 'thumb');
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
[
|
||||
'upload_id' => $uploadId,
|
||||
'path' => "tmp/drafts/{$uploadId}/main/night-city.jpg",
|
||||
'type' => 'main',
|
||||
'hash' => $hash,
|
||||
'size' => 10,
|
||||
'mime' => 'image/jpeg',
|
||||
'created_at' => now(),
|
||||
],
|
||||
[
|
||||
'upload_id' => $uploadId,
|
||||
'path' => "tmp/drafts/{$uploadId}/preview.webp",
|
||||
'type' => 'preview',
|
||||
'hash' => null,
|
||||
'size' => 7,
|
||||
'mime' => 'image/webp',
|
||||
'created_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$published = app(PublishService::class)->publish($uploadId, $user);
|
||||
|
||||
expect($published)->toBeInstanceOf(Upload::class);
|
||||
expect($published->status)->toBe('published');
|
||||
expect($published->published_at)->not->toBeNull();
|
||||
expect($published->final_path)->toBe('files/artworks/aa/bb/' . $hash);
|
||||
|
||||
Storage::disk('local')->assertMissing("tmp/drafts/{$uploadId}/main/night-city.jpg");
|
||||
Storage::disk('local')->assertExists('files/artworks/aa/bb/' . $hash . '/main/night-city.jpg');
|
||||
|
||||
$updatedMain = DB::table('upload_files')
|
||||
->where('upload_id', $uploadId)
|
||||
->where('type', 'main')
|
||||
->value('path');
|
||||
expect($updatedMain)->toBe('files/artworks/aa/bb/' . $hash . '/main/night-city.jpg');
|
||||
});
|
||||
|
||||
it('does not delete temp files on publish failure', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$categoryId = createCategoryForUploadPublishFeatureTests();
|
||||
$uploadId = (string) Str::uuid();
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $uploadId,
|
||||
'user_id' => $user->id,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'moderation_status' => 'approved',
|
||||
'title' => 'Will Fail',
|
||||
'category_id' => $categoryId,
|
||||
'is_scanned' => true,
|
||||
'has_tags' => true,
|
||||
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/file.jpg", 'jpg-binary');
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview');
|
||||
|
||||
// missing hash should trigger failure and preserve temp files
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $uploadId,
|
||||
'path' => "tmp/drafts/{$uploadId}/main/file.jpg",
|
||||
'type' => 'main',
|
||||
'hash' => null,
|
||||
'size' => 10,
|
||||
'mime' => 'image/jpeg',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
expect(fn () => app(PublishService::class)->publish($uploadId, $user))
|
||||
->toThrow(RuntimeException::class);
|
||||
|
||||
Storage::disk('local')->assertExists("tmp/drafts/{$uploadId}/main/file.jpg");
|
||||
$status = DB::table('uploads')->where('id', $uploadId)->value('status');
|
||||
expect($status)->toBe('draft');
|
||||
});
|
||||
|
||||
it('publish persists generated slug when missing', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$categoryId = createCategoryForUploadPublishFeatureTests();
|
||||
$uploadId = (string) Str::uuid();
|
||||
$hash = '0011aabbccddeeff2233';
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $uploadId,
|
||||
'user_id' => $user->id,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'moderation_status' => 'approved',
|
||||
'processing_state' => 'ready',
|
||||
'title' => 'My Amazing Artwork',
|
||||
'slug' => null,
|
||||
'category_id' => $categoryId,
|
||||
'is_scanned' => true,
|
||||
'has_tags' => true,
|
||||
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/file.jpg", 'jpg-binary');
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview');
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $uploadId,
|
||||
'path' => "tmp/drafts/{$uploadId}/main/file.jpg",
|
||||
'type' => 'main',
|
||||
'hash' => $hash,
|
||||
'size' => 10,
|
||||
'mime' => 'image/jpeg',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PublishService::class)->publish($uploadId, $user);
|
||||
|
||||
expect(DB::table('uploads')->where('id', $uploadId)->value('slug'))->toBe('my-amazing-artwork');
|
||||
});
|
||||
|
||||
it('publish slug uniqueness appends numeric suffix for published uploads', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$categoryId = createCategoryForUploadPublishFeatureTests();
|
||||
|
||||
$firstUploadId = (string) Str::uuid();
|
||||
$secondUploadId = (string) Str::uuid();
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
[
|
||||
'id' => $firstUploadId,
|
||||
'user_id' => $user->id,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'moderation_status' => 'approved',
|
||||
'processing_state' => 'ready',
|
||||
'title' => 'Duplicate Title',
|
||||
'slug' => null,
|
||||
'category_id' => $categoryId,
|
||||
'is_scanned' => true,
|
||||
'has_tags' => true,
|
||||
'preview_path' => "tmp/drafts/{$firstUploadId}/preview.webp",
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'id' => $secondUploadId,
|
||||
'user_id' => $user->id,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'moderation_status' => 'approved',
|
||||
'processing_state' => 'ready',
|
||||
'title' => 'Duplicate Title',
|
||||
'slug' => null,
|
||||
'category_id' => $categoryId,
|
||||
'is_scanned' => true,
|
||||
'has_tags' => true,
|
||||
'preview_path' => "tmp/drafts/{$secondUploadId}/preview.webp",
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$firstUploadId}/main/file.jpg", 'first');
|
||||
Storage::disk('local')->put("tmp/drafts/{$firstUploadId}/preview.webp", 'preview');
|
||||
Storage::disk('local')->put("tmp/drafts/{$secondUploadId}/main/file.jpg", 'second');
|
||||
Storage::disk('local')->put("tmp/drafts/{$secondUploadId}/preview.webp", 'preview');
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
[
|
||||
'upload_id' => $firstUploadId,
|
||||
'path' => "tmp/drafts/{$firstUploadId}/main/file.jpg",
|
||||
'type' => 'main',
|
||||
'hash' => 'aa11bb22cc33dd44',
|
||||
'size' => 10,
|
||||
'mime' => 'image/jpeg',
|
||||
'created_at' => now(),
|
||||
],
|
||||
[
|
||||
'upload_id' => $secondUploadId,
|
||||
'path' => "tmp/drafts/{$secondUploadId}/main/file.jpg",
|
||||
'type' => 'main',
|
||||
'hash' => 'ee11ff22cc33dd44',
|
||||
'size' => 10,
|
||||
'mime' => 'image/jpeg',
|
||||
'created_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
app(PublishService::class)->publish($firstUploadId, $user);
|
||||
app(PublishService::class)->publish($secondUploadId, $user);
|
||||
|
||||
expect(DB::table('uploads')->where('id', $firstUploadId)->value('slug'))->toBe('duplicate-title');
|
||||
expect(DB::table('uploads')->where('id', $secondUploadId)->value('slug'))->toBe('duplicate-title-2');
|
||||
});
|
||||
196
tests/Feature/Uploads/UploadQuotaTest.php
Normal file
196
tests/Feature/Uploads/UploadQuotaTest.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createUploadRowForQuota(int $userId, array $overrides = []): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
|
||||
$defaults = [
|
||||
'id' => $id,
|
||||
'user_id' => $userId,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'title' => null,
|
||||
'slug' => null,
|
||||
'category_id' => null,
|
||||
'description' => null,
|
||||
'tags' => null,
|
||||
'license' => null,
|
||||
'nsfw' => false,
|
||||
'is_scanned' => false,
|
||||
'has_tags' => false,
|
||||
'preview_path' => null,
|
||||
'published_at' => null,
|
||||
'final_path' => null,
|
||||
'expires_at' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
DB::table('uploads')->insert(array_merge($defaults, $overrides));
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
function attachMainUploadFileForQuota(string $uploadId, int $size, string $hash = 'hash-main'): void
|
||||
{
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $uploadId,
|
||||
'path' => "tmp/drafts/{$uploadId}/main/file.bin",
|
||||
'type' => 'main',
|
||||
'hash' => $hash,
|
||||
'size' => $size,
|
||||
'mime' => 'application/octet-stream',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('enforces draft count limit', function () {
|
||||
Storage::fake('local');
|
||||
config(['uploads.draft_quota.max_drafts_per_user' => 1]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
createUploadRowForQuota($user->id, ['status' => 'draft']);
|
||||
|
||||
$main = UploadedFile::fake()->image('wallpaper.jpg', 600, 400);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
|
||||
'main' => $main,
|
||||
]);
|
||||
|
||||
$response->assertStatus(429)
|
||||
->assertJsonPath('message', 'draft_limit')
|
||||
->assertJsonPath('code', 'draft_limit');
|
||||
});
|
||||
|
||||
it('enforces draft storage limit', function () {
|
||||
Storage::fake('local');
|
||||
config([
|
||||
'uploads.draft_quota.max_drafts_per_user' => 20,
|
||||
'uploads.draft_quota.max_draft_storage_mb_per_user' => 1,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$existingDraftId = createUploadRowForQuota($user->id, ['status' => 'draft']);
|
||||
attachMainUploadFileForQuota($existingDraftId, 400 * 1024, 'existing-hash');
|
||||
|
||||
$main = UploadedFile::fake()->create('large.jpg', 700, 'image/jpeg');
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
|
||||
'main' => $main,
|
||||
]);
|
||||
|
||||
$response->assertStatus(413)
|
||||
->assertJsonPath('message', 'storage_limit')
|
||||
->assertJsonPath('code', 'storage_limit');
|
||||
});
|
||||
|
||||
it('blocks duplicate hash when policy is block', function () {
|
||||
Storage::fake('local');
|
||||
config([
|
||||
'uploads.draft_quota.max_drafts_per_user' => 20,
|
||||
'uploads.draft_quota.duplicate_hash_policy' => 'block',
|
||||
]);
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$uploader = User::factory()->create();
|
||||
|
||||
$main = UploadedFile::fake()->image('dupe.jpg', 400, 400);
|
||||
$hash = hash_file('sha256', $main->getPathname());
|
||||
|
||||
$publishedUploadId = createUploadRowForQuota($owner->id, [
|
||||
'status' => 'published',
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
attachMainUploadFileForQuota($publishedUploadId, (int) $main->getSize(), $hash);
|
||||
|
||||
$response = $this->actingAs($uploader)->postJson('/api/uploads/preload', [
|
||||
'main' => $main,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('message', 'duplicate_upload')
|
||||
->assertJsonPath('code', 'duplicate_upload');
|
||||
});
|
||||
|
||||
it('allows duplicate hash and returns warning when policy is warn', function () {
|
||||
Storage::fake('local');
|
||||
config([
|
||||
'uploads.draft_quota.max_drafts_per_user' => 20,
|
||||
'uploads.draft_quota.duplicate_hash_policy' => 'warn',
|
||||
]);
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$uploader = User::factory()->create();
|
||||
|
||||
$main = UploadedFile::fake()->image('dupe-warn.jpg', 400, 400);
|
||||
$hash = hash_file('sha256', $main->getPathname());
|
||||
|
||||
$publishedUploadId = createUploadRowForQuota($owner->id, [
|
||||
'status' => 'published',
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
attachMainUploadFileForQuota($publishedUploadId, (int) $main->getSize(), $hash);
|
||||
|
||||
$response = $this->actingAs($uploader)->postJson('/api/uploads/preload', [
|
||||
'main' => $main,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure(['upload_id', 'status', 'expires_at', 'warnings'])
|
||||
->assertJsonPath('warnings.0', 'duplicate_hash');
|
||||
});
|
||||
|
||||
it('does not count published uploads as drafts', function () {
|
||||
Storage::fake('local');
|
||||
config(['uploads.draft_quota.max_drafts_per_user' => 1]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
createUploadRowForQuota($user->id, [
|
||||
'status' => 'published',
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$main = UploadedFile::fake()->image('new.jpg', 640, 480);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
|
||||
'main' => $main,
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJsonStructure([
|
||||
'upload_id',
|
||||
'status',
|
||||
'expires_at',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns stable machine codes for quota errors', function () {
|
||||
Storage::fake('local');
|
||||
config(['uploads.draft_quota.max_drafts_per_user' => 1]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
createUploadRowForQuota($user->id, ['status' => 'draft']);
|
||||
|
||||
$main = UploadedFile::fake()->image('machine-code.jpg', 600, 400);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
|
||||
'main' => $main,
|
||||
]);
|
||||
|
||||
$response->assertStatus(429)
|
||||
->assertJson([
|
||||
'message' => 'draft_limit',
|
||||
'code' => 'draft_limit',
|
||||
]);
|
||||
});
|
||||
129
tests/Feature/Uploads/UploadStatusTest.php
Normal file
129
tests/Feature/Uploads/UploadStatusTest.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createUploadForStatusTests(int $userId, array $overrides = []): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
|
||||
$defaults = [
|
||||
'id' => $id,
|
||||
'user_id' => $userId,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'title' => null,
|
||||
'slug' => null,
|
||||
'category_id' => null,
|
||||
'description' => null,
|
||||
'tags' => null,
|
||||
'license' => null,
|
||||
'nsfw' => false,
|
||||
'is_scanned' => false,
|
||||
'has_tags' => false,
|
||||
'preview_path' => null,
|
||||
'published_at' => null,
|
||||
'final_path' => null,
|
||||
'expires_at' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
DB::table('uploads')->insert(array_merge($defaults, $overrides));
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
it('owner sees processing status payload', function () {
|
||||
$owner = User::factory()->create();
|
||||
$uploadId = createUploadForStatusTests($owner->id, [
|
||||
'status' => 'draft',
|
||||
'processing_state' => 'analyzing_tags',
|
||||
'is_scanned' => true,
|
||||
'preview_path' => 'tmp/drafts/preview.webp',
|
||||
'has_tags' => false,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($owner)->getJson("/api/uploads/{$uploadId}/status");
|
||||
|
||||
$response->assertOk()->assertJson([
|
||||
'id' => $uploadId,
|
||||
'status' => 'draft',
|
||||
'is_scanned' => true,
|
||||
'preview_ready' => true,
|
||||
'has_tags' => false,
|
||||
'processing_state' => 'analyzing_tags',
|
||||
]);
|
||||
});
|
||||
|
||||
it('other user is denied', function () {
|
||||
$owner = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
|
||||
$uploadId = createUploadForStatusTests($owner->id);
|
||||
|
||||
$response = $this->actingAs($other)->getJson("/api/uploads/{$uploadId}/status");
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
it('returns explicit processing states', function (array $input, string $expectedState) {
|
||||
$owner = User::factory()->create();
|
||||
|
||||
$uploadId = createUploadForStatusTests($owner->id, $input);
|
||||
|
||||
$response = $this->actingAs($owner)->getJson("/api/uploads/{$uploadId}/status");
|
||||
|
||||
$response->assertOk()->assertJsonPath('processing_state', $expectedState);
|
||||
})
|
||||
->with([
|
||||
'pending scan' => [[
|
||||
'status' => 'draft',
|
||||
'processing_state' => 'pending_scan',
|
||||
], 'pending_scan'],
|
||||
'scanning status' => [[
|
||||
'status' => 'scanning',
|
||||
'processing_state' => 'scanning',
|
||||
], 'scanning'],
|
||||
'generating preview' => [[
|
||||
'status' => 'draft',
|
||||
'processing_state' => 'generating_preview',
|
||||
], 'generating_preview'],
|
||||
'analyzing tags' => [[
|
||||
'status' => 'draft',
|
||||
'processing_state' => 'analyzing_tags',
|
||||
], 'analyzing_tags'],
|
||||
'ready' => [[
|
||||
'status' => 'draft',
|
||||
'processing_state' => 'ready',
|
||||
], 'ready'],
|
||||
]);
|
||||
|
||||
it('returns rejected processing step when upload is rejected', function () {
|
||||
$owner = User::factory()->create();
|
||||
$uploadId = createUploadForStatusTests($owner->id, [
|
||||
'status' => 'rejected',
|
||||
'processing_state' => 'rejected',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($owner)->getJson("/api/uploads/{$uploadId}/status");
|
||||
|
||||
$response->assertOk()->assertJsonPath('processing_state', 'rejected');
|
||||
});
|
||||
|
||||
it('returns published processing step when upload is published', function () {
|
||||
$owner = User::factory()->create();
|
||||
$uploadId = createUploadForStatusTests($owner->id, [
|
||||
'status' => 'published',
|
||||
'processing_state' => 'published',
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($owner)->getJson("/api/uploads/{$uploadId}/status");
|
||||
|
||||
$response->assertOk()->assertJsonPath('processing_state', 'published');
|
||||
});
|
||||
60
tests/Feature/Uploads/VirusScanJobTest.php
Normal file
60
tests/Feature/Uploads/VirusScanJobTest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Uploads\Jobs\PreviewGenerationJob;
|
||||
use App\Uploads\Jobs\VirusScanJob;
|
||||
use App\Services\Uploads\UploadScanService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('dispatches PreviewGenerationJob when VirusScanJob marks upload clean', function () {
|
||||
Storage::fake('local');
|
||||
Bus::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$uploadId = (string) Str::uuid();
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $uploadId,
|
||||
'user_id' => $user->id,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'is_scanned' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$mainPath = "tmp/drafts/{$uploadId}/main/main.jpg";
|
||||
Storage::disk('local')->put($mainPath, 'fake-image-content');
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $uploadId,
|
||||
'path' => $mainPath,
|
||||
'type' => 'main',
|
||||
'hash' => null,
|
||||
'size' => 18,
|
||||
'mime' => 'image/jpeg',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$job = new VirusScanJob($uploadId);
|
||||
$job->handle(app(UploadScanService::class));
|
||||
|
||||
$this->assertDatabaseHas('uploads', [
|
||||
'id' => $uploadId,
|
||||
'is_scanned' => 1,
|
||||
]);
|
||||
|
||||
Bus::assertDispatched(PreviewGenerationJob::class, function (PreviewGenerationJob $queuedJob) use ($uploadId) {
|
||||
$reflect = new ReflectionClass($queuedJob);
|
||||
$property = $reflect->getProperty('uploadId');
|
||||
$property->setAccessible(true);
|
||||
|
||||
return $property->getValue($queuedJob) === $uploadId;
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user