2026-07-23 14:51:13 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Enums\UserWorkspace\Role;
|
|
|
|
|
use App\Models\Account;
|
|
|
|
|
use App\Models\Media;
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
use App\Models\Workspace;
|
|
|
|
|
use App\Services\Media\ChunkedAssetReceiver;
|
|
|
|
|
use App\Services\Media\ChunkedCloudUploader;
|
|
|
|
|
use App\Services\Media\ChunkReceipt;
|
|
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
Storage::fake();
|
|
|
|
|
|
|
|
|
|
$this->account = Account::factory()->create();
|
|
|
|
|
$this->user = User::factory()->create(['account_id' => $this->account->id]);
|
|
|
|
|
$this->account->update(['owner_id' => $this->user->id]);
|
|
|
|
|
$this->workspace = Workspace::factory()->create([
|
|
|
|
|
'account_id' => $this->account->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
|
|
|
|
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('chunk receipt in-progress response only exposes progress', function () {
|
|
|
|
|
$response = ChunkReceipt::inProgress(42)->toResponse();
|
|
|
|
|
|
|
|
|
|
expect($response->getData(true))->toBe([
|
|
|
|
|
'done' => false,
|
|
|
|
|
'progress' => 42,
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('chunk receipt completed response merges media resource fields', function () {
|
|
|
|
|
$media = Media::factory()->create([
|
|
|
|
|
'mediable_type' => $this->workspace->getMorphClass(),
|
|
|
|
|
'mediable_id' => $this->workspace->id,
|
|
|
|
|
'collection' => 'assets',
|
|
|
|
|
'type' => 'video',
|
|
|
|
|
'path' => 'medias/clip.mp4',
|
|
|
|
|
'original_filename' => 'clip.mp4',
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
'size' => 12,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$payload = ChunkReceipt::completed($media)->toResponse()->getData(true);
|
|
|
|
|
|
|
|
|
|
expect($payload['done'])->toBeTrue();
|
|
|
|
|
expect($payload['id'])->toBe($media->id);
|
|
|
|
|
expect($payload['path'])->toBe('medias/clip.mp4');
|
|
|
|
|
expect($payload['type'])->toBe('video');
|
|
|
|
|
expect($payload['original_filename'])->toBe('clip.mp4');
|
|
|
|
|
expect($payload)->toHaveKeys(['url', 'mime_type', 'size', 'meta', 'created_at']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('receiver assembles locally when multipart is not used', function () {
|
|
|
|
|
$cloud = Mockery::mock(ChunkedCloudUploader::class);
|
|
|
|
|
$cloud->shouldReceive('shouldUseMultipart')->with('clip.mp4')->andReturn(false);
|
|
|
|
|
$cloud->shouldNotReceive('receiveChunk');
|
|
|
|
|
|
|
|
|
|
$receiver = new ChunkedAssetReceiver($cloud);
|
|
|
|
|
$bytes = "\0\0\0\x18ftypmp42\0\0\0\0mp42isom".str_repeat("\0", 64);
|
|
|
|
|
|
|
|
|
|
$receipt = $receiver->receive(
|
|
|
|
|
$this->workspace,
|
|
|
|
|
$this->user,
|
|
|
|
|
'clip.mp4',
|
|
|
|
|
$bytes,
|
|
|
|
|
0,
|
|
|
|
|
strlen($bytes) - 1,
|
|
|
|
|
strlen($bytes),
|
fix: chunked upload session collision + workspace name i18n (#263)
* fix: give each chunked upload attempt a unique server-side identifier
The upload session identifier was derived only from user+filename+size
(ChunkedAssetReceiver::receive), with no per-attempt nonce. Two genuinely
concurrent attempts of the same file (e.g. closing and reopening the media
picker mid-upload, then re-uploading the same file) collided on the same
Redis cache key / temp file, producing RuntimeException("Chunked cloud
upload session expired or missing.") on the multipart/cloud path and
silent byte corruption on the local-assemble path.
The frontend now mints a UUID per upload attempt (X-Upload-Id header) that
gets folded into the identifier. Falls back to the old formula when the
header is absent, so any already-loaded frontend bundle keeps working.
Also guards the media picker's dropzone against re-triggering an upload
while one is in flight, and aborts the in-flight fetch when the dialog
unmounts mid-upload.
Fixes Nightwatch issue #23.
* fix: explicitly type upload_id when passing to receive()
Matches the existing explicit (int) casts on the sibling validated()
calls in the same method — validated() returns mixed, so this keeps
the nullable-string contract explicit instead of relying on an
implicit runtime type.
* style: inline the upload_id null-safe cast
Drop the intermediate variable so all receive() arguments read as a
single expression each, matching the sibling validated() casts.
* fix: require X-Upload-Id instead of falling back to the legacy identifier
Nullable upload_id only preserved the old (collision-prone) formula for
clients that omit the header — it didn't actually protect them. Making it
required closes that gap outright: a request without the header now fails
loud (422) instead of silently falling back to the vulnerable identifier.
ChunkedAssetReceiver::receive() now takes a required $attemptId. Updated
every existing test hitting app.assets.store-chunked (ChunkedCloudUploadTest,
ChunkedAssetReceiverTest, ChunkedUploadFilenameEncodingTest, AssetControllerTest)
to send a real upload id, and added a regression test asserting the endpoint
rejects a request with no X-Upload-Id header.
* fix: localize hardcoded workspace name validation messages
StoreWorkspaceRequest had its custom messages() hardcoded in pt-BR
regardless of the user's locale; UpdateWorkspaceRequest had the same
bug hardcoded in English. Both now go through __('validation.required'
/ 'validation.max.string') with the already-localized
workspaces.create.name attribute label (present in all 16 lang/
directories), matching the pattern already used by
StoreWorkspaceInviteRequest.
Unrelated to the chunked upload fix, but caught while reviewing this
file's messages() convention.
* simplify: drop messages() override on workspace name validation
Laravel already localizes the generic required/max messages from
lang/{locale}/validation.php automatically — no need to hand-roll
messages() for standard rules with no custom copy.
* fix: localize StoreChunkedAssetRequest validation messages
Drop the hardcoded English messages for required/ends_with rules —
Laravel's own localized validation.php messages already cover them
adequately (ends_with's generic message is actually more useful, since
it lists the accepted extensions). total_size.max still needs a custom
message (the rule is in raw bytes, unreadable without MB conversion),
so it now goes through __('assets.upload.file_too_large') with the key
added to all 16 lang/ locales.
Also fixed test flakiness discovered while touching this file:
ChunkedCloudUploadTest used random_bytes() for the first mp4 chunk,
which occasionally collides with an unrelated magic number (MZ/PE,
SIMH tape, ...) and makes finfo misdetect the mime type. Replaced with
real mp4 header bytes padded with nulls, so detection is deterministic.
* fix: address final code review findings
- ChunkedAssetReceiver: use double-quoted interpolation instead of
concatenation for the identifier hash, per project convention.
- AssetControllerTest: two chunked-upload rejection tests didn't send
X-Upload-Id, so their 422 assertions could pass for the wrong reason
(upload_id.required) instead of the field they claim to cover. Added
the header and asserted the specific validation error field.
- GalleryBrowser: centralize the upload-in-progress guard as a single
check at the top of uploadFiles() instead of three separate checks
at each entry point (click/select/drop) — matches the single-source-
of-truth pattern already used in PhotoUpload.vue.
- GalleryBrowser: show a toast when an in-flight upload is aborted
(dialog closed mid-upload) instead of silently discarding it with no
feedback. New assets.upload.cancelled key added to all 16 lang/
locales.
2026-08-09 17:06:47 +00:00
|
|
|
'attempt-1',
|
2026-07-23 14:51:13 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect($receipt->done)->toBeTrue();
|
|
|
|
|
expect($receipt->media)->toBeInstanceOf(Media::class);
|
|
|
|
|
expect($receipt->media->type->value)->toBe('video');
|
|
|
|
|
Storage::assertExists($receipt->media->path);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('receiver reports progress for intermediate local chunks', function () {
|
|
|
|
|
$cloud = Mockery::mock(ChunkedCloudUploader::class);
|
|
|
|
|
$cloud->shouldReceive('shouldUseMultipart')->andReturn(false);
|
|
|
|
|
|
|
|
|
|
$receiver = new ChunkedAssetReceiver($cloud);
|
|
|
|
|
$part = str_repeat('a', 100);
|
|
|
|
|
$total = 250;
|
|
|
|
|
|
|
|
|
|
$receipt = $receiver->receive(
|
|
|
|
|
$this->workspace,
|
|
|
|
|
$this->user,
|
|
|
|
|
'clip.mp4',
|
|
|
|
|
$part,
|
|
|
|
|
0,
|
|
|
|
|
99,
|
|
|
|
|
$total,
|
fix: chunked upload session collision + workspace name i18n (#263)
* fix: give each chunked upload attempt a unique server-side identifier
The upload session identifier was derived only from user+filename+size
(ChunkedAssetReceiver::receive), with no per-attempt nonce. Two genuinely
concurrent attempts of the same file (e.g. closing and reopening the media
picker mid-upload, then re-uploading the same file) collided on the same
Redis cache key / temp file, producing RuntimeException("Chunked cloud
upload session expired or missing.") on the multipart/cloud path and
silent byte corruption on the local-assemble path.
The frontend now mints a UUID per upload attempt (X-Upload-Id header) that
gets folded into the identifier. Falls back to the old formula when the
header is absent, so any already-loaded frontend bundle keeps working.
Also guards the media picker's dropzone against re-triggering an upload
while one is in flight, and aborts the in-flight fetch when the dialog
unmounts mid-upload.
Fixes Nightwatch issue #23.
* fix: explicitly type upload_id when passing to receive()
Matches the existing explicit (int) casts on the sibling validated()
calls in the same method — validated() returns mixed, so this keeps
the nullable-string contract explicit instead of relying on an
implicit runtime type.
* style: inline the upload_id null-safe cast
Drop the intermediate variable so all receive() arguments read as a
single expression each, matching the sibling validated() casts.
* fix: require X-Upload-Id instead of falling back to the legacy identifier
Nullable upload_id only preserved the old (collision-prone) formula for
clients that omit the header — it didn't actually protect them. Making it
required closes that gap outright: a request without the header now fails
loud (422) instead of silently falling back to the vulnerable identifier.
ChunkedAssetReceiver::receive() now takes a required $attemptId. Updated
every existing test hitting app.assets.store-chunked (ChunkedCloudUploadTest,
ChunkedAssetReceiverTest, ChunkedUploadFilenameEncodingTest, AssetControllerTest)
to send a real upload id, and added a regression test asserting the endpoint
rejects a request with no X-Upload-Id header.
* fix: localize hardcoded workspace name validation messages
StoreWorkspaceRequest had its custom messages() hardcoded in pt-BR
regardless of the user's locale; UpdateWorkspaceRequest had the same
bug hardcoded in English. Both now go through __('validation.required'
/ 'validation.max.string') with the already-localized
workspaces.create.name attribute label (present in all 16 lang/
directories), matching the pattern already used by
StoreWorkspaceInviteRequest.
Unrelated to the chunked upload fix, but caught while reviewing this
file's messages() convention.
* simplify: drop messages() override on workspace name validation
Laravel already localizes the generic required/max messages from
lang/{locale}/validation.php automatically — no need to hand-roll
messages() for standard rules with no custom copy.
* fix: localize StoreChunkedAssetRequest validation messages
Drop the hardcoded English messages for required/ends_with rules —
Laravel's own localized validation.php messages already cover them
adequately (ends_with's generic message is actually more useful, since
it lists the accepted extensions). total_size.max still needs a custom
message (the rule is in raw bytes, unreadable without MB conversion),
so it now goes through __('assets.upload.file_too_large') with the key
added to all 16 lang/ locales.
Also fixed test flakiness discovered while touching this file:
ChunkedCloudUploadTest used random_bytes() for the first mp4 chunk,
which occasionally collides with an unrelated magic number (MZ/PE,
SIMH tape, ...) and makes finfo misdetect the mime type. Replaced with
real mp4 header bytes padded with nulls, so detection is deterministic.
* fix: address final code review findings
- ChunkedAssetReceiver: use double-quoted interpolation instead of
concatenation for the identifier hash, per project convention.
- AssetControllerTest: two chunked-upload rejection tests didn't send
X-Upload-Id, so their 422 assertions could pass for the wrong reason
(upload_id.required) instead of the field they claim to cover. Added
the header and asserted the specific validation error field.
- GalleryBrowser: centralize the upload-in-progress guard as a single
check at the top of uploadFiles() instead of three separate checks
at each entry point (click/select/drop) — matches the single-source-
of-truth pattern already used in PhotoUpload.vue.
- GalleryBrowser: show a toast when an in-flight upload is aborted
(dialog closed mid-upload) instead of silently discarding it with no
feedback. New assets.upload.cancelled key added to all 16 lang/
locales.
2026-08-09 17:06:47 +00:00
|
|
|
'attempt-1',
|
2026-07-23 14:51:13 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect($receipt->done)->toBeFalse();
|
|
|
|
|
expect($receipt->progress)->toBe(40);
|
|
|
|
|
expect($this->workspace->getMedia('assets')->count())->toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('receiver completes multipart uploads through the cloud uploader', function () {
|
|
|
|
|
$cloud = Mockery::mock(ChunkedCloudUploader::class);
|
|
|
|
|
$cloud->shouldReceive('shouldUseMultipart')->with('clip.mp4')->andReturn(true);
|
|
|
|
|
$cloud->shouldReceive('receiveChunk')
|
|
|
|
|
->once()
|
|
|
|
|
->andReturn([
|
|
|
|
|
'done' => true,
|
|
|
|
|
'progress' => 100,
|
|
|
|
|
'path' => 'medias/from-cloud.mp4',
|
|
|
|
|
'size' => 12,
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$receiver = new ChunkedAssetReceiver($cloud);
|
|
|
|
|
|
|
|
|
|
$receipt = $receiver->receive(
|
|
|
|
|
$this->workspace,
|
|
|
|
|
$this->user,
|
|
|
|
|
'clip.mp4',
|
|
|
|
|
'fake-video!!',
|
|
|
|
|
0,
|
|
|
|
|
11,
|
|
|
|
|
12,
|
fix: chunked upload session collision + workspace name i18n (#263)
* fix: give each chunked upload attempt a unique server-side identifier
The upload session identifier was derived only from user+filename+size
(ChunkedAssetReceiver::receive), with no per-attempt nonce. Two genuinely
concurrent attempts of the same file (e.g. closing and reopening the media
picker mid-upload, then re-uploading the same file) collided on the same
Redis cache key / temp file, producing RuntimeException("Chunked cloud
upload session expired or missing.") on the multipart/cloud path and
silent byte corruption on the local-assemble path.
The frontend now mints a UUID per upload attempt (X-Upload-Id header) that
gets folded into the identifier. Falls back to the old formula when the
header is absent, so any already-loaded frontend bundle keeps working.
Also guards the media picker's dropzone against re-triggering an upload
while one is in flight, and aborts the in-flight fetch when the dialog
unmounts mid-upload.
Fixes Nightwatch issue #23.
* fix: explicitly type upload_id when passing to receive()
Matches the existing explicit (int) casts on the sibling validated()
calls in the same method — validated() returns mixed, so this keeps
the nullable-string contract explicit instead of relying on an
implicit runtime type.
* style: inline the upload_id null-safe cast
Drop the intermediate variable so all receive() arguments read as a
single expression each, matching the sibling validated() casts.
* fix: require X-Upload-Id instead of falling back to the legacy identifier
Nullable upload_id only preserved the old (collision-prone) formula for
clients that omit the header — it didn't actually protect them. Making it
required closes that gap outright: a request without the header now fails
loud (422) instead of silently falling back to the vulnerable identifier.
ChunkedAssetReceiver::receive() now takes a required $attemptId. Updated
every existing test hitting app.assets.store-chunked (ChunkedCloudUploadTest,
ChunkedAssetReceiverTest, ChunkedUploadFilenameEncodingTest, AssetControllerTest)
to send a real upload id, and added a regression test asserting the endpoint
rejects a request with no X-Upload-Id header.
* fix: localize hardcoded workspace name validation messages
StoreWorkspaceRequest had its custom messages() hardcoded in pt-BR
regardless of the user's locale; UpdateWorkspaceRequest had the same
bug hardcoded in English. Both now go through __('validation.required'
/ 'validation.max.string') with the already-localized
workspaces.create.name attribute label (present in all 16 lang/
directories), matching the pattern already used by
StoreWorkspaceInviteRequest.
Unrelated to the chunked upload fix, but caught while reviewing this
file's messages() convention.
* simplify: drop messages() override on workspace name validation
Laravel already localizes the generic required/max messages from
lang/{locale}/validation.php automatically — no need to hand-roll
messages() for standard rules with no custom copy.
* fix: localize StoreChunkedAssetRequest validation messages
Drop the hardcoded English messages for required/ends_with rules —
Laravel's own localized validation.php messages already cover them
adequately (ends_with's generic message is actually more useful, since
it lists the accepted extensions). total_size.max still needs a custom
message (the rule is in raw bytes, unreadable without MB conversion),
so it now goes through __('assets.upload.file_too_large') with the key
added to all 16 lang/ locales.
Also fixed test flakiness discovered while touching this file:
ChunkedCloudUploadTest used random_bytes() for the first mp4 chunk,
which occasionally collides with an unrelated magic number (MZ/PE,
SIMH tape, ...) and makes finfo misdetect the mime type. Replaced with
real mp4 header bytes padded with nulls, so detection is deterministic.
* fix: address final code review findings
- ChunkedAssetReceiver: use double-quoted interpolation instead of
concatenation for the identifier hash, per project convention.
- AssetControllerTest: two chunked-upload rejection tests didn't send
X-Upload-Id, so their 422 assertions could pass for the wrong reason
(upload_id.required) instead of the field they claim to cover. Added
the header and asserted the specific validation error field.
- GalleryBrowser: centralize the upload-in-progress guard as a single
check at the top of uploadFiles() instead of three separate checks
at each entry point (click/select/drop) — matches the single-source-
of-truth pattern already used in PhotoUpload.vue.
- GalleryBrowser: show a toast when an in-flight upload is aborted
(dialog closed mid-upload) instead of silently discarding it with no
feedback. New assets.upload.cancelled key added to all 16 lang/
locales.
2026-08-09 17:06:47 +00:00
|
|
|
'attempt-1',
|
2026-07-23 14:51:13 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect($receipt->done)->toBeTrue();
|
|
|
|
|
expect($receipt->media->path)->toBe('medias/from-cloud.mp4');
|
|
|
|
|
expect($receipt->media->size)->toBe(12);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('receiver returns in-progress when multipart chunk is not final', function () {
|
|
|
|
|
$cloud = Mockery::mock(ChunkedCloudUploader::class);
|
|
|
|
|
$cloud->shouldReceive('shouldUseMultipart')->andReturn(true);
|
|
|
|
|
$cloud->shouldReceive('receiveChunk')
|
|
|
|
|
->once()
|
|
|
|
|
->andReturn(['done' => false, 'progress' => 55]);
|
|
|
|
|
|
|
|
|
|
$receiver = new ChunkedAssetReceiver($cloud);
|
|
|
|
|
|
|
|
|
|
$receipt = $receiver->receive(
|
|
|
|
|
$this->workspace,
|
|
|
|
|
$this->user,
|
|
|
|
|
'clip.mp4',
|
|
|
|
|
str_repeat('a', 100),
|
|
|
|
|
0,
|
|
|
|
|
99,
|
|
|
|
|
200,
|
fix: chunked upload session collision + workspace name i18n (#263)
* fix: give each chunked upload attempt a unique server-side identifier
The upload session identifier was derived only from user+filename+size
(ChunkedAssetReceiver::receive), with no per-attempt nonce. Two genuinely
concurrent attempts of the same file (e.g. closing and reopening the media
picker mid-upload, then re-uploading the same file) collided on the same
Redis cache key / temp file, producing RuntimeException("Chunked cloud
upload session expired or missing.") on the multipart/cloud path and
silent byte corruption on the local-assemble path.
The frontend now mints a UUID per upload attempt (X-Upload-Id header) that
gets folded into the identifier. Falls back to the old formula when the
header is absent, so any already-loaded frontend bundle keeps working.
Also guards the media picker's dropzone against re-triggering an upload
while one is in flight, and aborts the in-flight fetch when the dialog
unmounts mid-upload.
Fixes Nightwatch issue #23.
* fix: explicitly type upload_id when passing to receive()
Matches the existing explicit (int) casts on the sibling validated()
calls in the same method — validated() returns mixed, so this keeps
the nullable-string contract explicit instead of relying on an
implicit runtime type.
* style: inline the upload_id null-safe cast
Drop the intermediate variable so all receive() arguments read as a
single expression each, matching the sibling validated() casts.
* fix: require X-Upload-Id instead of falling back to the legacy identifier
Nullable upload_id only preserved the old (collision-prone) formula for
clients that omit the header — it didn't actually protect them. Making it
required closes that gap outright: a request without the header now fails
loud (422) instead of silently falling back to the vulnerable identifier.
ChunkedAssetReceiver::receive() now takes a required $attemptId. Updated
every existing test hitting app.assets.store-chunked (ChunkedCloudUploadTest,
ChunkedAssetReceiverTest, ChunkedUploadFilenameEncodingTest, AssetControllerTest)
to send a real upload id, and added a regression test asserting the endpoint
rejects a request with no X-Upload-Id header.
* fix: localize hardcoded workspace name validation messages
StoreWorkspaceRequest had its custom messages() hardcoded in pt-BR
regardless of the user's locale; UpdateWorkspaceRequest had the same
bug hardcoded in English. Both now go through __('validation.required'
/ 'validation.max.string') with the already-localized
workspaces.create.name attribute label (present in all 16 lang/
directories), matching the pattern already used by
StoreWorkspaceInviteRequest.
Unrelated to the chunked upload fix, but caught while reviewing this
file's messages() convention.
* simplify: drop messages() override on workspace name validation
Laravel already localizes the generic required/max messages from
lang/{locale}/validation.php automatically — no need to hand-roll
messages() for standard rules with no custom copy.
* fix: localize StoreChunkedAssetRequest validation messages
Drop the hardcoded English messages for required/ends_with rules —
Laravel's own localized validation.php messages already cover them
adequately (ends_with's generic message is actually more useful, since
it lists the accepted extensions). total_size.max still needs a custom
message (the rule is in raw bytes, unreadable without MB conversion),
so it now goes through __('assets.upload.file_too_large') with the key
added to all 16 lang/ locales.
Also fixed test flakiness discovered while touching this file:
ChunkedCloudUploadTest used random_bytes() for the first mp4 chunk,
which occasionally collides with an unrelated magic number (MZ/PE,
SIMH tape, ...) and makes finfo misdetect the mime type. Replaced with
real mp4 header bytes padded with nulls, so detection is deterministic.
* fix: address final code review findings
- ChunkedAssetReceiver: use double-quoted interpolation instead of
concatenation for the identifier hash, per project convention.
- AssetControllerTest: two chunked-upload rejection tests didn't send
X-Upload-Id, so their 422 assertions could pass for the wrong reason
(upload_id.required) instead of the field they claim to cover. Added
the header and asserted the specific validation error field.
- GalleryBrowser: centralize the upload-in-progress guard as a single
check at the top of uploadFiles() instead of three separate checks
at each entry point (click/select/drop) — matches the single-source-
of-truth pattern already used in PhotoUpload.vue.
- GalleryBrowser: show a toast when an in-flight upload is aborted
(dialog closed mid-upload) instead of silently discarding it with no
feedback. New assets.upload.cancelled key added to all 16 lang/
locales.
2026-08-09 17:06:47 +00:00
|
|
|
'attempt-1',
|
2026-07-23 14:51:13 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect($receipt->done)->toBeFalse();
|
|
|
|
|
expect($receipt->progress)->toBe(55);
|
|
|
|
|
expect($this->workspace->getMedia('assets')->count())->toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('receiver deletes the cloud object when media registration fails after multipart', function () {
|
|
|
|
|
Storage::put('medias/orphan.mp4', 'uploaded-bytes');
|
|
|
|
|
|
|
|
|
|
$cloud = Mockery::mock(ChunkedCloudUploader::class);
|
|
|
|
|
$cloud->shouldReceive('shouldUseMultipart')->andReturn(true);
|
|
|
|
|
$cloud->shouldReceive('receiveChunk')
|
|
|
|
|
->once()
|
|
|
|
|
->andReturn([
|
|
|
|
|
'done' => true,
|
|
|
|
|
'progress' => 100,
|
|
|
|
|
'path' => 'medias/orphan.mp4',
|
|
|
|
|
'size' => 14,
|
|
|
|
|
'mime_type' => 'application/zip',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$receiver = new ChunkedAssetReceiver($cloud);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $receiver->receive(
|
|
|
|
|
$this->workspace,
|
|
|
|
|
$this->user,
|
|
|
|
|
'clip.mp4',
|
|
|
|
|
'uploaded-bytes',
|
|
|
|
|
0,
|
|
|
|
|
13,
|
|
|
|
|
14,
|
fix: chunked upload session collision + workspace name i18n (#263)
* fix: give each chunked upload attempt a unique server-side identifier
The upload session identifier was derived only from user+filename+size
(ChunkedAssetReceiver::receive), with no per-attempt nonce. Two genuinely
concurrent attempts of the same file (e.g. closing and reopening the media
picker mid-upload, then re-uploading the same file) collided on the same
Redis cache key / temp file, producing RuntimeException("Chunked cloud
upload session expired or missing.") on the multipart/cloud path and
silent byte corruption on the local-assemble path.
The frontend now mints a UUID per upload attempt (X-Upload-Id header) that
gets folded into the identifier. Falls back to the old formula when the
header is absent, so any already-loaded frontend bundle keeps working.
Also guards the media picker's dropzone against re-triggering an upload
while one is in flight, and aborts the in-flight fetch when the dialog
unmounts mid-upload.
Fixes Nightwatch issue #23.
* fix: explicitly type upload_id when passing to receive()
Matches the existing explicit (int) casts on the sibling validated()
calls in the same method — validated() returns mixed, so this keeps
the nullable-string contract explicit instead of relying on an
implicit runtime type.
* style: inline the upload_id null-safe cast
Drop the intermediate variable so all receive() arguments read as a
single expression each, matching the sibling validated() casts.
* fix: require X-Upload-Id instead of falling back to the legacy identifier
Nullable upload_id only preserved the old (collision-prone) formula for
clients that omit the header — it didn't actually protect them. Making it
required closes that gap outright: a request without the header now fails
loud (422) instead of silently falling back to the vulnerable identifier.
ChunkedAssetReceiver::receive() now takes a required $attemptId. Updated
every existing test hitting app.assets.store-chunked (ChunkedCloudUploadTest,
ChunkedAssetReceiverTest, ChunkedUploadFilenameEncodingTest, AssetControllerTest)
to send a real upload id, and added a regression test asserting the endpoint
rejects a request with no X-Upload-Id header.
* fix: localize hardcoded workspace name validation messages
StoreWorkspaceRequest had its custom messages() hardcoded in pt-BR
regardless of the user's locale; UpdateWorkspaceRequest had the same
bug hardcoded in English. Both now go through __('validation.required'
/ 'validation.max.string') with the already-localized
workspaces.create.name attribute label (present in all 16 lang/
directories), matching the pattern already used by
StoreWorkspaceInviteRequest.
Unrelated to the chunked upload fix, but caught while reviewing this
file's messages() convention.
* simplify: drop messages() override on workspace name validation
Laravel already localizes the generic required/max messages from
lang/{locale}/validation.php automatically — no need to hand-roll
messages() for standard rules with no custom copy.
* fix: localize StoreChunkedAssetRequest validation messages
Drop the hardcoded English messages for required/ends_with rules —
Laravel's own localized validation.php messages already cover them
adequately (ends_with's generic message is actually more useful, since
it lists the accepted extensions). total_size.max still needs a custom
message (the rule is in raw bytes, unreadable without MB conversion),
so it now goes through __('assets.upload.file_too_large') with the key
added to all 16 lang/ locales.
Also fixed test flakiness discovered while touching this file:
ChunkedCloudUploadTest used random_bytes() for the first mp4 chunk,
which occasionally collides with an unrelated magic number (MZ/PE,
SIMH tape, ...) and makes finfo misdetect the mime type. Replaced with
real mp4 header bytes padded with nulls, so detection is deterministic.
* fix: address final code review findings
- ChunkedAssetReceiver: use double-quoted interpolation instead of
concatenation for the identifier hash, per project convention.
- AssetControllerTest: two chunked-upload rejection tests didn't send
X-Upload-Id, so their 422 assertions could pass for the wrong reason
(upload_id.required) instead of the field they claim to cover. Added
the header and asserted the specific validation error field.
- GalleryBrowser: centralize the upload-in-progress guard as a single
check at the top of uploadFiles() instead of three separate checks
at each entry point (click/select/drop) — matches the single-source-
of-truth pattern already used in PhotoUpload.vue.
- GalleryBrowser: show a toast when an in-flight upload is aborted
(dialog closed mid-upload) instead of silently discarding it with no
feedback. New assets.upload.cancelled key added to all 16 lang/
locales.
2026-08-09 17:06:47 +00:00
|
|
|
'attempt-1',
|
2026-07-23 14:51:13 +00:00
|
|
|
))->toThrow(InvalidArgumentException::class);
|
|
|
|
|
|
|
|
|
|
Storage::assertMissing('medias/orphan.mp4');
|
|
|
|
|
expect($this->workspace->getMedia('assets')->count())->toBe(0);
|
|
|
|
|
});
|