Upload beautify

This commit is contained in:
2026-02-14 15:14:12 +01:00
parent e129618910
commit 79192345e3
249 changed files with 24436 additions and 1021 deletions

View 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',
]);
});

View 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;
});
});

View 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']);
});

View 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);
});

View 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);
});

View 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;
});
});

View 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');
});

View 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);
});

View 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');
});

View 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',
]);
});

View 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');
});

View 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;
});
});