Compare commits

..

7 Commits

Author SHA1 Message Date
Constantin Graf
95bd15864a Fixed formatting 2026-08-31 12:07:08 +02:00
Constantin Graf
8f446306ef Test still-running email preference 2026-08-31 11:52:55 +02:00
Constantin Graf
e74af760dd Move email preference to notifications section 2026-08-31 11:43:32 +02:00
Constantin Graf
425175c43e Add still-running email preference 2026-08-31 11:14:39 +02:00
Andrew Herron
28aba7b27a Stopped downloads opening a new tab now that it's guaranteed to be an attachment. 2026-08-28 17:30:27 +02:00
Andrew Herron
453d5ed066 Set content-disposition 'attachment' for both local and S3 downloads. Added a bunch of tests. Fixes #1148. 2026-08-28 17:30:27 +02:00
Gregor Vostrak
ed3ba0135b fix undefined table-state helper in projects search e2e test 2026-08-28 13:10:36 +02:00
22 changed files with 512 additions and 7 deletions

View File

@@ -51,7 +51,8 @@ class TimeEntrySendStillRunningMailsCommand extends Command
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
$query->where('is_placeholder', '=', false)
->where('send_time_entry_still_running_email', '=', true);
})
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {

View File

@@ -28,7 +28,9 @@ class ExportController extends Controller
$filepath = $exportService->export($organization);
$downloadUrl = Storage::disk(config('filesystems.private'))
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
->temporaryUrl($filepath, Carbon::now()->addMinutes(10), [
'ResponseContentDisposition' => 'attachment; filename="'.basename($filepath).'"',
]);
return new JsonResponse([
'success' => true,

View File

@@ -336,7 +336,9 @@ class TimeEntryController extends Controller
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
->temporaryUrl($path, now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
]);
}
@@ -547,7 +549,9 @@ class TimeEntryController extends Controller
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
->temporaryUrl($path, now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
]);
}

View File

@@ -124,6 +124,10 @@ class UserController extends Controller
$user->week_start = $request->getWeekStart();
}
if ($request->getSendTimeEntryStillRunningEmail() !== null) {
$user->send_time_entry_still_running_email = $request->getSendTimeEntryStillRunningEmail();
}
$user->save();
if ($emailToVerify !== null) {

View File

@@ -58,6 +58,9 @@ class UserUpdateRequest extends BaseFormRequest
'week_start' => [
Rule::enum(Weekday::class),
],
'send_time_entry_still_running_email' => [
'boolean',
],
];
}
@@ -81,6 +84,13 @@ class UserUpdateRequest extends BaseFormRequest
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
}
public function getSendTimeEntryStillRunningEmail(): ?bool
{
return $this->has('send_time_entry_still_running_email')
? $this->boolean('send_time_entry_still_running_email')
: null;
}
public function hasPhotoKey(): bool
{
return $this->has('photo');

View File

@@ -36,6 +36,8 @@ class UserResource extends BaseResource
'timezone' => $this->resource->timezone,
/** @var Weekday $week_start Starting day of the week */
'week_start' => $this->resource->week_start->value,
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
];
}
}

View File

@@ -42,6 +42,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $timezone
* @property bool $is_placeholder
* @property Weekday $week_start
* @property bool $send_time_entry_still_running_email
* @property string|null $profile_photo_path
* @property-read Organization|null $currentOrganization
* @property-read string $profile_photo_url
@@ -108,6 +109,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
'is_admin' => 'boolean',
'is_placeholder' => 'boolean',
'week_start' => Weekday::class,
'send_time_entry_still_running_email' => 'boolean',
];
/**
@@ -117,6 +119,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
*/
protected $attributes = [
'week_start' => Weekday::Monday,
'send_time_entry_still_running_email' => true,
];
/**

View File

@@ -20,6 +20,7 @@ use App\Service\BillingContract;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\IpLookup\NoIpLookupService;
use App\Service\PermissionStore;
use DateTimeInterface;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
@@ -29,8 +30,13 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AppServiceProvider extends ServiceProvider
{
@@ -98,6 +104,27 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
$this->app->bind(BillingContract::class);
// Storage
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
// so mirror it through the signed query parameters of the storage route.
$privateDisk = config('filesystems.private');
if (config('filesystems.disks.'.$privateDisk.'.driver') === 'local') {
$disk = Storage::disk($privateDisk);
$disk->serveUsing(function (Request $request, string $path, array $headers) use ($disk): StreamedResponse {
return $disk->response($path, null, $headers, $request->query('disposition', 'inline'));
});
$disk->buildTemporaryUrlsUsing(function (string $path, DateTimeInterface $expiration, array $options) use ($privateDisk): string {
$parameters = array_filter([
'path' => $path,
'disposition' => isset($options['ResponseContentDisposition'])
? Str::before($options['ResponseContentDisposition'], ';')
: null,
]);
return url(URL::temporarySignedRoute('storage.'.$privateDisk, $expiration, $parameters, absolute: false));
});
}
// Routing
Route::model('member', Member::class);
Route::model('invitation', OrganizationInvitation::class);

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->boolean('send_time_entry_still_running_email')->default(true)->after('week_start');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('send_time_entry_still_running_email');
});
}
};

View File

@@ -20,6 +20,12 @@ function profileInformationForm(page: Page) {
.locator('xpath=ancestor::*[descendant::form][1]');
}
function notificationSettingsForm(page: Page) {
return page
.getByRole('heading', { name: 'Notifications', exact: true })
.locator('xpath=ancestor::*[descendant::form][1]');
}
async function saveProfileForm(page: Page): Promise<void> {
const form = profileInformationForm(page);
await form.getByRole('button', { name: 'Save' }).click();
@@ -50,6 +56,22 @@ test('week-start change persists across reload', async ({ page }) => {
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
});
test('still-running email notification setting persists across reload', async ({ page }) => {
await goToProfilePage(page);
const form = notificationSettingsForm(page);
const checkbox = form.getByLabel('Still-running time entry reminders');
await expect(checkbox).toBeChecked();
await checkbox.uncheck();
await form.getByRole('button', { name: 'Save' }).click();
await expect(form.getByText('Saved.', { exact: true })).toBeVisible();
await page.reload();
await expect(
notificationSettingsForm(page).getByLabel('Still-running time entry reminders')
).not.toBeChecked();
});
test('profile photo can be uploaded, persists across reload, and can be removed', async ({
page,
}) => {

View File

@@ -1297,7 +1297,7 @@ test('test that searching projects by name works', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: otherProjectName });
await goToProjectsOverview(page);
await clearProjectTableState(page);
await clearTableState(page, 'project-table-state');
await page.reload();
await expect(page.getByText(matchingProjectName)).toBeVisible({ timeout: 10000 });
await expect(page.getByText(otherProjectName)).toBeVisible();

View File

@@ -9,7 +9,7 @@ const showExportModal = defineModel('show', { default: false });
function downloadCurrentExport() {
if (props.exportUrl) {
window.open(props.exportUrl, '_blank')?.focus();
window.open(props.exportUrl, '_self');
}
}
</script>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import ActionMessage from '@/Components/ActionMessage.vue';
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { Checkbox } from '@/packages/ui/src';
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldLabel,
} from '@/packages/ui/src/field';
import { getApiValidationFieldErrors } from '@/utils/apiValidation';
import { useUpdateUserMutation, useUserQuery } from '@/utils/useUserQuery';
const { user } = useUserQuery();
const updateUser = useUpdateUserMutation();
const sendTimeEntryStillRunningEmail = ref(true);
const recentlySaved = ref(false);
watch(
user,
(currentUser, previousUser) => {
if (currentUser && previousUser === undefined) {
sendTimeEntryStillRunningEmail.value = currentUser.send_time_entry_still_running_email;
}
},
{ immediate: true }
);
const isUserLoaded = computed(() => user.value !== undefined);
const isSaveDisabled = computed(() => !isUserLoaded.value || updateUser.isPending.value);
const fieldErrors = computed<Record<string, string>>(() =>
getApiValidationFieldErrors(updateUser.error.value)
);
async function save() {
if (isSaveDisabled.value || !user.value) return;
if (sendTimeEntryStillRunningEmail.value === user.value.send_time_entry_still_running_email) {
flashSaved();
return;
}
try {
const updatedUser = await updateUser.mutateAsync({
userId: user.value.id,
body: {
send_time_entry_still_running_email: sendTimeEntryStillRunningEmail.value,
},
});
sendTimeEntryStillRunningEmail.value = updatedUser.send_time_entry_still_running_email;
flashSaved();
} catch {
// 422: field errors render below. Other errors: toast handled in the mutation.
}
}
function flashSaved() {
recentlySaved.value = true;
setTimeout(() => (recentlySaved.value = false), 2000);
}
</script>
<template>
<FormSection @submitted="save">
<template #title>Notifications</template>
<template #description>Choose which email notifications you want to receive.</template>
<template #form>
<div class="col-span-6 sm:col-span-4">
<Field orientation="horizontal">
<Checkbox
id="send_time_entry_still_running_email"
v-model:checked="sendTimeEntryStillRunningEmail"
:disabled="!isUserLoaded" />
<FieldContent>
<FieldLabel for="send_time_entry_still_running_email">
Still-running time entry reminders
</FieldLabel>
<FieldDescription>
Email me when a time entry has been running for more than 8 hours.
</FieldDescription>
</FieldContent>
</Field>
<FieldError v-if="fieldErrors.send_time_entry_still_running_email">
{{ fieldErrors.send_time_entry_still_running_email }}
</FieldError>
</div>
</template>
<template #actions>
<ActionMessage :on="recentlySaved" class="me-3">Saved.</ActionMessage>
<PrimaryButton :class="{ 'opacity-25': isSaveDisabled }" :disabled="isSaveDisabled">
Save
</PrimaryButton>
</template>
</FormSection>
</template>

View File

@@ -9,6 +9,7 @@ import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfile
import type { Session } from '@/types/jetstream';
import ApiTokensForm from '@/Pages/Profile/Partials/ApiTokensForm.vue';
import ThemeForm from '@/Pages/Profile/Partials/ThemeForm.vue';
import NotificationSettingsForm from '@/Pages/Profile/Partials/NotificationSettingsForm.vue';
defineProps<{
confirmsTwoFactorAuthentication: boolean;
@@ -36,6 +37,12 @@ defineProps<{
<SectionBorder />
</div>
<div>
<NotificationSettingsForm />
<SectionBorder />
</div>
<div>
<UpdatePasswordForm class="mt-10 sm:mt-0" />

View File

@@ -705,6 +705,7 @@ const UserResource = z
profile_photo_url: z.string(),
timezone: z.string(),
week_start: Weekday,
send_time_entry_still_running_email: z.boolean(),
})
.passthrough();
const UserUpdateRequest = z
@@ -714,6 +715,7 @@ const UserUpdateRequest = z
photo: z.union([z.string(), z.null()]),
timezone: z.string(),
week_start: Weekday,
send_time_entry_still_running_email: z.boolean(),
})
.partial()
.passthrough();

View File

@@ -76,6 +76,7 @@ export interface User {
two_factor_confirmed_at: string | null;
timezone: string;
week_start: string;
send_time_entry_still_running_email: boolean;
profile_photo_url: string;
organizations: Organization[];
clients: Client[];

View File

@@ -159,4 +159,26 @@ class TimeEntrySendStillRunningMailsCommandTest extends TestCaseWithDatabase
$this->assertSame("Sending still running time entry emails...\n".
"Finished sending 0 still running time entry emails...\n", $output);
}
public function test_does_not_send_emails_to_users_who_disabled_them(): void
{
// Arrange
$user = $this->createUserWithPermission();
$user->user->send_time_entry_still_running_email = false;
$user->user->save();
$timeEntry = TimeEntry::factory()->forMember($user->member)->create([
'start' => Carbon::now()->subHours(8)->subSecond(),
'end' => null,
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails');
// Assert
Mail::assertNothingOutgoing();
$this->assertNull($timeEntry->fresh()->still_active_email_sent_at);
$this->assertSame(Command::SUCCESS, $exitCode);
$this->assertSame("Sending still running time entry emails...\n".
"Finished sending 0 still running time entry emails...\n", Artisan::output());
}
}

View File

@@ -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;
};
}
}

View File

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

View File

@@ -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

View File

@@ -45,6 +45,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
'profile_photo_url' => $data->user->profile_photo_url,
'timezone' => $data->user->timezone,
'week_start' => $data->user->week_start->value,
'send_time_entry_still_running_email' => true,
],
]);
}
@@ -131,7 +132,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonValidationErrors('organization_id');
}
public function test_update_changes_user_name_timezone_and_week_start(): void
public function test_update_changes_user_profile_settings(): void
{
// Arrange
$data = $this->createUserWithPermission();
@@ -142,6 +143,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
'name' => 'Updated Name',
'timezone' => 'America/New_York',
'week_start' => Weekday::Sunday->value,
'send_time_entry_still_running_email' => false,
]);
// Assert
@@ -152,6 +154,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
'name' => 'Updated Name',
'timezone' => 'America/New_York',
'week_start' => Weekday::Sunday->value,
'send_time_entry_still_running_email' => false,
],
]);
@@ -159,6 +162,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
$this->assertSame('Updated Name', $user->name);
$this->assertSame('America/New_York', $user->timezone);
$this->assertSame(Weekday::Sunday, $user->week_start);
$this->assertFalse($user->send_time_entry_still_running_email);
}
public function test_update_does_not_change_user_fields_that_are_not_given(): void
@@ -503,6 +507,22 @@ class UserEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonValidationErrors(['week_start']);
}
public function test_update_fails_if_send_time_entry_still_running_email_is_not_boolean(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'send_time_entry_still_running_email' => 'not-a-boolean',
]);
// Assert
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['send_time_entry_still_running_email']);
}
public function test_update_fails_if_photo_is_not_a_string(): void
{
// Arrange

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