mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-12 15:53:19 +01:00
Set content-disposition 'attachment' for both local and S3 downloads. Added a bunch of tests. Fixes #1148.
This commit is contained in:
committed by
Gregor Vostrak
parent
ed3ba0135b
commit
453d5ed066
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use Closure;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
|
||||
@@ -16,4 +19,26 @@ class ApiEndpointTestAbstract extends TestCaseWithDatabase
|
||||
}
|
||||
$response->assertStatus($statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the temporary URL builder of the private disk to capture the options
|
||||
* passed to temporaryUrl. Returns a closure that yields the captured options.
|
||||
*
|
||||
* @return Closure(): (array<string, mixed>|null)
|
||||
*/
|
||||
protected function captureTemporaryUrlOptions(): Closure
|
||||
{
|
||||
$captured = null;
|
||||
Storage::disk(config('filesystems.private'))->buildTemporaryUrlsUsing(
|
||||
function (string $path, DateTimeInterface $expiration, array $options) use (&$captured): string {
|
||||
$captured = $options;
|
||||
|
||||
return 'https://storage.fake/'.$path;
|
||||
}
|
||||
);
|
||||
|
||||
return function () use (&$captured): ?array {
|
||||
return $captured;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,4 +90,35 @@ class ExportEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertStringContainsString($filepath, $response->json('download_url'));
|
||||
}
|
||||
|
||||
public function test_export_requests_the_download_url_as_an_attachment(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission([
|
||||
'export',
|
||||
]);
|
||||
$filepath = 'exports/export_test.zip';
|
||||
$this->mock(ExportService::class, function (MockInterface $mock) use (&$user, $filepath): void {
|
||||
$mock->shouldReceive('export')
|
||||
->withArgs(function (Organization $organization) use (&$user): bool {
|
||||
return $organization->is($user->organization);
|
||||
})
|
||||
->andReturn($filepath)
|
||||
->once();
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
$capturedOptions = $this->captureTemporaryUrlOptions();
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.export.export', [
|
||||
'organization' => $user->organization->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$options = $capturedOptions();
|
||||
$this->assertIsArray($options);
|
||||
$this->assertSame('attachment; filename="export_test.zip"', $options['ResponseContentDisposition'] ?? null);
|
||||
$this->assertSame('https://storage.fake/'.$filepath, $response->json('download_url'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,6 +947,35 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$this->assertResponseCode($response, 200);
|
||||
}
|
||||
|
||||
public function test_index_export_endpoint_requests_the_download_url_as_an_attachment(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:all',
|
||||
]);
|
||||
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||
Passport::actingAs($data->user);
|
||||
$capturedOptions = $this->captureTemporaryUrlOptions();
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.index-export', [
|
||||
$data->organization->getKey(),
|
||||
'format' => ExportFormat::CSV,
|
||||
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
$options = $capturedOptions();
|
||||
$this->assertIsArray($options);
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/^attachment; filename="time-entries-export-.+\.csv"$/',
|
||||
$options['ResponseContentDisposition'] ?? ''
|
||||
);
|
||||
$this->assertStringStartsWith('https://storage.fake/exports/', $response->json('download_url'));
|
||||
}
|
||||
|
||||
public function test_index_export_endpoint_can_create_a_detailed_time_entry_report_in_format_ods(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -1321,6 +1350,38 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$this->assertResponseCode($response, 200);
|
||||
}
|
||||
|
||||
public function test_aggregate_export_endpoint_requests_the_download_url_as_an_attachment(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:all',
|
||||
]);
|
||||
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
|
||||
Passport::actingAs($data->user);
|
||||
$capturedOptions = $this->captureTemporaryUrlOptions();
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
|
||||
$data->organization->getKey(),
|
||||
'format' => ExportFormat::CSV,
|
||||
'group' => TimeEntryAggregationType::Client,
|
||||
'sub_group' => TimeEntryAggregationType::Project,
|
||||
'history_group' => TimeEntryAggregationTypeInterval::Month,
|
||||
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
|
||||
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
$options = $capturedOptions();
|
||||
$this->assertIsArray($options);
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/^attachment; filename="time-entries-report-.+\.csv"$/',
|
||||
$options['ResponseContentDisposition'] ?? ''
|
||||
);
|
||||
$this->assertStringStartsWith('https://storage.fake/exports/', $response->json('download_url'));
|
||||
}
|
||||
|
||||
public function test_aggregate_export_endpoints_can_create_a_csv_report_as_employee_role_with_show_billable_rate(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
134
tests/Unit/Endpoint/Web/FileDownloadEndpointTest.php
Normal file
134
tests/Unit/Endpoint/Web/FileDownloadEndpointTest.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Web;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Tests the attachment downloads of the private disk via Laravel's storage.{disk} route,
|
||||
* configured with serveUsing/buildTemporaryUrlsUsing in AppServiceProvider::boot.
|
||||
* These tests use the real private disk, because Storage::fake would replace the disk
|
||||
* instance and thereby remove that configuration.
|
||||
*/
|
||||
class FileDownloadEndpointTest extends EndpointTestAbstract
|
||||
{
|
||||
private function privateDisk(): Filesystem
|
||||
{
|
||||
return Storage::disk(config('filesystems.private'));
|
||||
}
|
||||
|
||||
public function test_temporary_url_with_attachment_disposition_serves_file_as_attachment(): void
|
||||
{
|
||||
// Arrange
|
||||
$disk = $this->privateDisk();
|
||||
$disk->put('exports/test-attachment.csv', 'Description,Duration');
|
||||
$url = $disk->temporaryUrl('exports/test-attachment.csv', now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="test-attachment.csv"',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->get($url);
|
||||
|
||||
// Assert
|
||||
$response->assertOk();
|
||||
$response->assertDownload('test-attachment.csv');
|
||||
$disk->delete('exports/test-attachment.csv');
|
||||
}
|
||||
|
||||
public function test_temporary_url_without_options_serves_file_inline(): void
|
||||
{
|
||||
// Arrange
|
||||
$disk = $this->privateDisk();
|
||||
$disk->put('exports/test-inline.csv', 'Description,Duration');
|
||||
$url = $disk->temporaryUrl('exports/test-inline.csv', now()->addMinutes(5));
|
||||
|
||||
// Act
|
||||
$response = $this->get($url);
|
||||
|
||||
// Assert
|
||||
$response->assertOk();
|
||||
$this->assertStringStartsWith('inline', (string) $response->headers->get('Content-Disposition'));
|
||||
$disk->delete('exports/test-inline.csv');
|
||||
}
|
||||
|
||||
public function test_download_fails_with_tampered_disposition(): void
|
||||
{
|
||||
// Arrange
|
||||
$url = $this->privateDisk()->temporaryUrl('exports/test-tampered.csv', now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="test-tampered.csv"',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->get(str_replace('attachment', 'inline', $url));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_download_fails_with_expired_signature(): void
|
||||
{
|
||||
// Arrange
|
||||
$url = $this->privateDisk()->temporaryUrl('exports/test-expired.csv', now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="test-expired.csv"',
|
||||
]);
|
||||
$this->travel(6)->minutes();
|
||||
|
||||
// Act
|
||||
$response = $this->get($url);
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_download_fails_if_file_does_not_exist(): void
|
||||
{
|
||||
// Arrange
|
||||
$url = $this->privateDisk()->temporaryUrl('exports/test-missing.csv', now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="test-missing.csv"',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->get($url);
|
||||
|
||||
// Assert
|
||||
$response->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_temporary_url_of_private_local_disk_points_to_storage_route_with_signed_disposition(): void
|
||||
{
|
||||
// Act
|
||||
$url = $this->privateDisk()->temporaryUrl('exports/test-export.pdf', now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="test-export.pdf"',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertStringContainsString('/storage/exports/test-export.pdf', $url);
|
||||
$this->assertStringContainsString('disposition=attachment', $url);
|
||||
$this->assertStringContainsString('signature=', $url);
|
||||
}
|
||||
|
||||
public function test_temporary_url_of_s3_disk_includes_content_disposition_from_options(): void
|
||||
{
|
||||
// Arrange
|
||||
config([
|
||||
'filesystems.disks.s3.key' => 'test-key',
|
||||
'filesystems.disks.s3.secret' => 'test-secret',
|
||||
'filesystems.disks.s3.region' => 'us-east-1',
|
||||
'filesystems.disks.s3.bucket' => 'test-bucket',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$url = Storage::disk('s3')->temporaryUrl('exports/test-export.pdf', now()->addMinutes(5), [
|
||||
'ResponseContentDisposition' => 'attachment; filename="test-export.pdf"',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$query = [];
|
||||
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
|
||||
$this->assertSame('attachment; filename="test-export.pdf"', $query['response-content-disposition'] ?? null);
|
||||
$this->assertArrayHasKey('X-Amz-Signature', $query);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user