mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
6e47d76ba6
Client file sharing, rebuilt from the ground up: a private area per client, resumable uploads, folders, groups and categories, sharing with expiry dates and download limits, comments, file versions, an activity log, a REST API, and sixteen languages. This repository begins here. ProjectSend 2 was developed privately, and that development history is not published — the previous generation remains available, with its own history, at projectsend/legacy. Free software under the GNU General Public License v2, or (at your option) any later version.
264 lines
9.8 KiB
PHP
264 lines
9.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLog;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Identity\UserType;
|
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
app(Settings::class)->set(Setting::Theme, 'default');
|
|
|
|
// The settings cache survives the per-test DB rollback, so an
|
|
// installation zone left over from a neighbouring test would decide
|
|
// what "the default" means here. Same reason LocalizationTest pins
|
|
// the locale settings in its own beforeEach.
|
|
app(Settings::class)->set(Setting::Timezone, '');
|
|
});
|
|
|
|
function timezones(): TimezoneRegistry
|
|
{
|
|
return app(TimezoneRegistry::class);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- resolution
|
|
|
|
test('an account with no timezone of its own falls back to the installation setting', function () {
|
|
app(Settings::class)->set(Setting::Timezone, 'Europe/Madrid');
|
|
|
|
$user = User::factory()->create(['timezone' => null]);
|
|
|
|
expect(timezones()->resolve($user))->toBe('Europe/Madrid');
|
|
});
|
|
|
|
test('an account timezone wins over the installation setting', function () {
|
|
app(Settings::class)->set(Setting::Timezone, 'Europe/Madrid');
|
|
|
|
$user = User::factory()->create(['timezone' => 'America/Argentina/Buenos_Aires']);
|
|
|
|
expect(timezones()->resolve($user))->toBe('America/Argentina/Buenos_Aires');
|
|
});
|
|
|
|
test('an anonymous visitor gets the installation setting', function () {
|
|
app(Settings::class)->set(Setting::Timezone, 'Pacific/Auckland');
|
|
|
|
expect(timezones()->resolve(null))->toBe('Pacific/Auckland');
|
|
});
|
|
|
|
test('an empty setting defers to APP_TIMEZONE', function () {
|
|
config()->set('app.timezone', 'Asia/Tokyo');
|
|
|
|
expect(timezones()->default())->toBe('Asia/Tokyo');
|
|
});
|
|
|
|
test('the last resort is UTC', function () {
|
|
config()->set('app.timezone', 'Not/AZone');
|
|
|
|
expect(timezones()->default())->toBe('UTC');
|
|
});
|
|
|
|
// A zone that was real when it was saved can stop existing under the
|
|
// host's next tzdata update. Degrading matters more than being right:
|
|
// throwing here would take down every page that prints a date.
|
|
test('a stored zone tzdata no longer knows degrades instead of throwing', function () {
|
|
app(Settings::class)->set(Setting::Timezone, 'Europe/Madrid');
|
|
|
|
$user = User::factory()->create(['timezone' => 'Mars/Olympus_Mons']);
|
|
|
|
expect(timezones()->resolve($user))->toBe('Europe/Madrid');
|
|
});
|
|
|
|
test('an unusable installation setting degrades too', function () {
|
|
app(Settings::class)->set(Setting::Timezone, 'Mars/Olympus_Mons');
|
|
config()->set('app.timezone', 'UTC');
|
|
|
|
expect(timezones()->resolve(null))->toBe('UTC');
|
|
});
|
|
|
|
test('the picker list is offered with a readable label and the offset it is on today', function () {
|
|
$madrid = collect(timezones()->options())->firstWhere('value', 'Europe/Madrid');
|
|
|
|
expect($madrid)->not->toBeNull()
|
|
// The region stays in the label so one search box finds it by
|
|
// either half.
|
|
->and($madrid['label'])->toBe('Europe / Madrid')
|
|
->and($madrid['offset'])->toMatch('/^UTC[+-]\d{2}:\d{2}$/');
|
|
|
|
$buenosAires = collect(timezones()->options())->firstWhere('value', 'America/Argentina/Buenos_Aires');
|
|
|
|
expect($buenosAires['label'])->toBe('America / Argentina / Buenos Aires')
|
|
->and($buenosAires['offset'])->toBe('UTC-03:00');
|
|
});
|
|
|
|
// ------------------------------------------------------------------ plumbing
|
|
|
|
test('the resolved zone and whether it was chosen are shared with every page', function () {
|
|
app(Settings::class)->set(Setting::Timezone, 'Europe/Madrid');
|
|
|
|
$user = User::factory()->create(['timezone' => null]);
|
|
|
|
$this->actingAs($user)->get('/dashboard')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->where('timezone', 'Europe/Madrid')
|
|
// False is what tells the frontend detector nobody has been
|
|
// asked yet.
|
|
->where('timezone_is_explicit', false),
|
|
);
|
|
|
|
$user->update(['timezone' => 'Asia/Tokyo']);
|
|
|
|
$this->actingAs($user)->get('/dashboard')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->where('timezone', 'Asia/Tokyo')
|
|
->where('timezone_is_explicit', true),
|
|
);
|
|
});
|
|
|
|
// The profile form saves it with everything else, so the screen keeps one
|
|
// Save button; PUT /timezone below stays for the browser detection, which
|
|
// has no form around it.
|
|
test('the profile form saves the timezone alongside the rest of the profile', function () {
|
|
$user = User::factory()->create(['timezone' => null]);
|
|
|
|
$this->actingAs($user)->patch('/settings/profile', [
|
|
'name' => 'Renamed Person',
|
|
'email' => $user->email,
|
|
'timezone' => 'Asia/Tokyo',
|
|
])->assertRedirect();
|
|
|
|
$user->refresh();
|
|
|
|
expect($user->timezone)->toBe('Asia/Tokyo')
|
|
->and($user->name)->toBe('Renamed Person');
|
|
});
|
|
|
|
test('a profile save that omits the timezone leaves the stored one alone', function () {
|
|
$user = User::factory()->create(['timezone' => 'Asia/Tokyo']);
|
|
|
|
$this->actingAs($user)->patch('/settings/profile', [
|
|
'name' => $user->name,
|
|
'email' => $user->email,
|
|
])->assertRedirect();
|
|
|
|
expect($user->fresh()->timezone)->toBe('Asia/Tokyo');
|
|
});
|
|
|
|
test('the profile form refuses a timezone the registry does not offer', function () {
|
|
$user = User::factory()->create(['timezone' => 'Europe/Madrid']);
|
|
|
|
$this->actingAs($user)->patch('/settings/profile', [
|
|
'name' => $user->name,
|
|
'email' => $user->email,
|
|
'timezone' => 'Mars/Olympus_Mons',
|
|
])->assertSessionHasErrors('timezone');
|
|
|
|
expect($user->fresh()->timezone)->toBe('Europe/Madrid');
|
|
});
|
|
|
|
test('a signed-in account can set its own timezone', function () {
|
|
$user = User::factory()->create(['timezone' => null]);
|
|
|
|
$this->actingAs($user)
|
|
->put('/timezone', ['timezone' => 'America/Argentina/Buenos_Aires'])
|
|
->assertRedirect();
|
|
|
|
expect($user->fresh()->timezone)->toBe('America/Argentina/Buenos_Aires');
|
|
});
|
|
|
|
test('a client can set their own timezone too', function () {
|
|
User::factory()->create();
|
|
$client = User::factory()->create(['type' => UserType::Client, 'timezone' => null]);
|
|
|
|
$this->actingAs($client)->put('/timezone', ['timezone' => 'Asia/Tokyo'])->assertRedirect();
|
|
|
|
expect($client->fresh()->timezone)->toBe('Asia/Tokyo');
|
|
});
|
|
|
|
test('a timezone the registry does not offer is rejected', function () {
|
|
$user = User::factory()->create(['timezone' => 'Europe/Madrid']);
|
|
|
|
$this->actingAs($user)
|
|
->put('/timezone', ['timezone' => 'Mars/Olympus_Mons'])
|
|
->assertSessionHasErrors('timezone');
|
|
|
|
expect($user->fresh()->timezone)->toBe('Europe/Madrid');
|
|
});
|
|
|
|
test('staff can set the installation timezone from general settings', function () {
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$this->get('/system/settings/general')->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('timezone', 'UTC'),
|
|
);
|
|
|
|
$this->patch('/system/settings/general', [
|
|
'site_name' => 'ProjectSend',
|
|
'timezone' => 'Pacific/Auckland',
|
|
])->assertRedirect();
|
|
|
|
expect(app(Settings::class)->get(Setting::Timezone))->toBe('Pacific/Auckland');
|
|
});
|
|
|
|
// -------------------------------------------------------- what it is applied to
|
|
|
|
test('the activity log filters on the viewer\'s calendar day, not the server\'s', function () {
|
|
$staff = User::factory()->create(['timezone' => 'America/Argentina/Buenos_Aires']);
|
|
|
|
// Dated well past "now" so the entries every other part of the setup
|
|
// logs (SetupCompleted, UserCreated) cannot land in the window and
|
|
// make the counts below mean something else.
|
|
app(ActivityLogger::class)->log(Action::FileDownloaded, $staff);
|
|
|
|
$entry = ActivityLog::query()->latest('id')->firstOrFail();
|
|
// 01:00 UTC on the 11th is 22:00 on the 10th in Buenos Aires, so
|
|
// filtering to the 10th there has to find it — and filtering to the
|
|
// 11th, which is what a UTC comparison would have matched, must not.
|
|
$entry->forceFill(['created_at' => '2027-03-11 01:00:00'])->save();
|
|
|
|
$this->actingAs($staff)
|
|
->get('/activity?from=2027-03-10&to=2027-03-10')
|
|
->assertInertia(fn (AssertableInertia $page) => $page->has('entries', 1)->where('entries.0.id', $entry->id));
|
|
|
|
$this->actingAs($staff)
|
|
->get('/activity?from=2027-03-11&to=2027-03-11')
|
|
->assertInertia(fn (AssertableInertia $page) => $page->has('entries', 0));
|
|
});
|
|
|
|
test('a file expiry set as a date means the end of that day where it was set', function () {
|
|
$staff = User::factory()->create(['timezone' => 'America/Argentina/Buenos_Aires']);
|
|
$file = File::factory()->create(['uploaded_by' => $staff->id]);
|
|
|
|
$this->actingAs($staff)->patch("/files/{$file->id}", [
|
|
'name' => $file->name,
|
|
'expires_at' => '2026-08-12',
|
|
])->assertRedirect();
|
|
|
|
// 23:59:59 on the 12th in UTC-3 is 02:59:59 on the 13th in UTC.
|
|
expect($file->fresh()->expires_at->toIso8601String())->toBe('2026-08-13T02:59:59+00:00');
|
|
});
|
|
|
|
test('an expiry date comes back out of the form as the date that went in', function () {
|
|
$staff = User::factory()->create(['timezone' => 'America/Argentina/Buenos_Aires']);
|
|
$file = File::factory()->create(['uploaded_by' => $staff->id]);
|
|
|
|
$this->actingAs($staff)->patch("/files/{$file->id}", [
|
|
'name' => $file->name,
|
|
'expires_at' => '2026-08-12',
|
|
]);
|
|
|
|
// The bug this pins down: read back in UTC the stored instant is the
|
|
// 13th, and the picker would reopen on the wrong day.
|
|
$this->actingAs($staff)->get("/files/{$file->id}")->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('file.expires_at', '2026-08-12'),
|
|
);
|
|
});
|