Fix the security audit's findings
Fixes the nine findings of security audit run 1 on 2.4.1 (T1-T11). F1: Uploads that are not complete now count only the bytes the server received, and a chunk that goes above the storage quota gets 422. An anonymous uploader can no longer fill the quota with declared sizes. F2: The share password and the admin login have a second limit without the address (20 attempts per minute per share and per e-mail), because a forged X-Forwarded-For changes the address. F3: The system password gate and the admin check now also run on Livewire updates, so an old uploader snapshot cannot register files. F4: The session keeps a fingerprint of the system password's hash. When the admin changes the system password, earlier verifications end. F5: The page after the upload opens only in the session that created the share, and only while recipients can still reach the share. F6: The share password limit counts an attempt before the password is checked, so parallel attempts cannot pass it. F7: The system password has the same two limits (5 per address, 20 in total per minute). F8: A password change or reset signs the user's other sessions out. F9: The only admin cannot delete the account, which would open /setup to every visitor. The setup wizard also holds the admin password to the password defaults, and Livewire applies EnsureAdmin to updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -151,7 +151,7 @@ test('the shares list excludes the private text from its file count and shows it
|
||||
->assertDontSee('3 files');
|
||||
});
|
||||
|
||||
test('shares whose files are still uploading are neither listed nor counted, but their bytes count as used space', function () {
|
||||
test('shares whose files are still uploading are neither listed nor counted, but only their received bytes count as used space', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
$completed = Share::factory()->create(['token' => 'completedshare01', 'total_size' => 1000]);
|
||||
ShareFile::factory()->for($completed)->create();
|
||||
@@ -165,5 +165,5 @@ test('shares whose files are still uploading are neither listed nor counted, but
|
||||
->assertViewHas('totalShares', 1)
|
||||
->assertViewHas('activeShares', 1)
|
||||
->assertViewHas('totalFiles', 1)
|
||||
->assertViewHas('usedSpace', 1500);
|
||||
->assertViewHas('usedSpace', 1000);
|
||||
});
|
||||
|
||||
@@ -37,6 +37,28 @@ test('users can not authenticate with invalid password', function () {
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('the login limit per e-mail holds when each attempt claims another address', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
for ($i = 1; $i <= 20; $i++) {
|
||||
$response = $this->withHeader('X-Forwarded-For', "203.0.113.$i")->post(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
if ($i === 1) {
|
||||
expect($response->status())->not->toBe(429);
|
||||
}
|
||||
}
|
||||
|
||||
$this->withHeader('X-Forwarded-For', '203.0.113.21')->post(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
])->assertTooManyRequests();
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('users with two factor enabled are redirected to two factor challenge', function () {
|
||||
if (! Features::canManageTwoFactorAuthentication()) {
|
||||
$this->markTestSkipped('Two-factor authentication is not enabled.');
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
@@ -54,14 +55,33 @@ test('upload page requires system password when configured', function () {
|
||||
});
|
||||
|
||||
test('upload page accessible after system password verified', function () {
|
||||
Setting::set('system_password', bcrypt('system-secret'));
|
||||
$systemPassword = bcrypt('system-secret');
|
||||
Setting::set('system_password', $systemPassword);
|
||||
|
||||
$response = $this->withSession(['system_password_verified' => true])
|
||||
$response = $this->withSession(['system_password_verified' => hash('sha256', $systemPassword)])
|
||||
->get(route('upload'));
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('a verification of an earlier system password no longer opens the upload page', function () {
|
||||
$oldSystemPassword = bcrypt('system-secret');
|
||||
Setting::set('system_password', $oldSystemPassword);
|
||||
$this->withSession(['system_password_verified' => hash('sha256', $oldSystemPassword)]);
|
||||
|
||||
Setting::set('system_password', bcrypt('new-system-secret'));
|
||||
|
||||
$this->get(route('upload'))->assertRedirect(route('system-password'));
|
||||
});
|
||||
|
||||
test('a verification stored before 2.4.2 no longer opens the upload page', function () {
|
||||
Setting::set('system_password', bcrypt('system-secret'));
|
||||
|
||||
$this->withSession(['system_password_verified' => true])
|
||||
->get(route('upload'))
|
||||
->assertRedirect(route('system-password'));
|
||||
});
|
||||
|
||||
test('file upload creates share', function () {
|
||||
Storage::fake('shares');
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
@@ -74,6 +94,7 @@ test('file upload creates share', function () {
|
||||
expect($share->isCompleted())->toBeTrue();
|
||||
expect($share->files->pluck('original_name')->all())->toBe(['document.pdf']);
|
||||
expect(session('pending_shares'))->not->toContain($share->token);
|
||||
expect(session('created_shares'))->toBe([$share->token]);
|
||||
});
|
||||
|
||||
test('registering files hands the browser each file\'s chunk URL, the share key and the file\'s nonce prefix', function () {
|
||||
@@ -377,6 +398,27 @@ test('file upload blocks when storage is full', function () {
|
||||
expect(ShareFile::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('files declared by one session without any chunk do not block another session', function () {
|
||||
Storage::fake('shares');
|
||||
Setting::set('max_file_size', 200 * 1024 * 1024);
|
||||
Setting::set('max_size_per_share', 1024 * 1024 * 1024);
|
||||
Setting::set('max_files_per_share', 10);
|
||||
Setting::set('max_storage_quota', 300 * 1024 * 1024);
|
||||
|
||||
Livewire::test(FileUploader::class)->call('registerFiles', [
|
||||
['name' => 'a.bin', 'size' => 100 * 1024 * 1024, 'path' => null],
|
||||
['name' => 'b.bin', 'size' => 100 * 1024 * 1024, 'path' => null],
|
||||
['name' => 'c.bin', 'size' => 100 * 1024 * 1024, 'path' => null],
|
||||
])->assertHasNoErrors('files');
|
||||
|
||||
session()->flush();
|
||||
$component = Livewire::test(FileUploader::class)
|
||||
->call('registerFiles', [['name' => 'd.txt', 'size' => 1024, 'path' => null]]);
|
||||
|
||||
$component->assertHasNoErrors('files');
|
||||
expect(ShareFile::query()->count())->toBe(4);
|
||||
});
|
||||
|
||||
test('system password prompt verifies correct password', function () {
|
||||
Setting::set('system_password', bcrypt('system-secret'));
|
||||
|
||||
@@ -394,3 +436,38 @@ test('system password prompt rejects incorrect password', function () {
|
||||
->call('verify')
|
||||
->assertHasErrors(['password']);
|
||||
});
|
||||
|
||||
test('the system password prompt refuses a sixth attempt from the same address, even with the correct password', function () {
|
||||
Setting::set('system_password', bcrypt('system-secret'));
|
||||
|
||||
$component = Livewire::test(SystemPasswordPrompt::class);
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$component->set('password', 'wrong')
|
||||
->call('verify')
|
||||
->assertHasErrors(['password']);
|
||||
}
|
||||
|
||||
$component->set('password', 'system-secret')
|
||||
->call('verify')
|
||||
->assertHasErrors(['password'])
|
||||
->assertSee('Too many attempts')
|
||||
->assertNoRedirect();
|
||||
});
|
||||
|
||||
test('the global system password limit refuses the correct password from any address', function () {
|
||||
Setting::set('system_password', bcrypt('system-secret'));
|
||||
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
RateLimiter::hit('system-password', 60);
|
||||
}
|
||||
|
||||
Livewire::test(SystemPasswordPrompt::class)
|
||||
->set('password', 'system-secret')
|
||||
->call('verify')
|
||||
->assertHasErrors(['password'])
|
||||
->assertSee('Too many attempts')
|
||||
->assertNoRedirect();
|
||||
|
||||
expect(session()->has('system_password_verified'))->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -26,7 +26,11 @@ test('every page is one page template, one h1 and the same width', function (Clo
|
||||
})->with([
|
||||
'upload' => [fn (TestCase $test): TestResponse => $test->get(route('upload'))],
|
||||
'download' => [fn (TestCase $test): TestResponse => $test->get(route('share.download', Share::factory()->withPassword()->create()))],
|
||||
'share created' => [fn (TestCase $test): TestResponse => $test->get(route('share.created', Share::factory()->create()))],
|
||||
'share created' => [function (TestCase $test): TestResponse {
|
||||
$share = Share::factory()->create();
|
||||
|
||||
return $test->withSession(['created_shares' => [$share->token]])->get(route('share.created', $share));
|
||||
}],
|
||||
'login' => [fn (TestCase $test): TestResponse => $test->get(route('login'))],
|
||||
'forgot password' => [fn (TestCase $test): TestResponse => $test->get(route('password.request'))],
|
||||
'reset password' => [fn (TestCase $test): TestResponse => $test->get(route('password.reset', 'token'))],
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
use App\Livewire\Admin\AdminSettings;
|
||||
use App\Livewire\FileUploader;
|
||||
use App\Livewire\ShareDownload;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Models\User;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Contracts\Hashing\Hasher;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
@@ -106,6 +109,101 @@ test('rate limiter clears after successful password verification', function () {
|
||||
expect(RateLimiter::remaining($rateLimitKey, 5))->toBe(5);
|
||||
});
|
||||
|
||||
test('a share password attempt is counted before the password is checked', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$share = app(ShareService::class)->createShare([
|
||||
['file' => UploadedFile::fake()->create('file.txt', 100), 'relativePath' => null],
|
||||
], [
|
||||
'password' => 'correct-password',
|
||||
]);
|
||||
|
||||
$ipKey = 'share-password:'.$share->token.'|127.0.0.1';
|
||||
$attemptsSeenDuringCheck = new ArrayObject;
|
||||
|
||||
Hash::swap(new class(Hash::getFacadeRoot(), $ipKey, $attemptsSeenDuringCheck) implements Hasher
|
||||
{
|
||||
public function __construct(private Hasher $inner, private string $ipKey, private ArrayObject $seen) {}
|
||||
|
||||
public function info($hashedValue): array
|
||||
{
|
||||
return $this->inner->info($hashedValue);
|
||||
}
|
||||
|
||||
public function make(#[SensitiveParameter] $value, array $options = []): string
|
||||
{
|
||||
return $this->inner->make($value, $options);
|
||||
}
|
||||
|
||||
public function check(#[SensitiveParameter] $value, $hashedValue, array $options = []): bool
|
||||
{
|
||||
$this->seen[] = RateLimiter::attempts($this->ipKey);
|
||||
|
||||
return $this->inner->check($value, $hashedValue, $options);
|
||||
}
|
||||
|
||||
public function needsRehash($hashedValue, array $options = []): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
Livewire::test(ShareDownload::class, ['share' => $share])
|
||||
->set('password', 'wrong-password')
|
||||
->call('verifyPassword');
|
||||
|
||||
expect($attemptsSeenDuringCheck->getArrayCopy())->not->toBeEmpty()
|
||||
->each->toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('the share-wide limit refuses the correct password from any address', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$share = app(ShareService::class)->createShare([
|
||||
['file' => UploadedFile::fake()->create('file.txt', 100), 'relativePath' => null],
|
||||
], [
|
||||
'password' => 'correct-password',
|
||||
]);
|
||||
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
RateLimiter::hit('share-password:'.$share->token, 60);
|
||||
}
|
||||
|
||||
Livewire::test(ShareDownload::class, ['share' => $share])
|
||||
->set('password', 'correct-password')
|
||||
->call('verifyPassword')
|
||||
->assertHasErrors(['password'])
|
||||
->assertSee('Too many attempts')
|
||||
->assertSet('authenticated', false);
|
||||
});
|
||||
|
||||
// --- The system password gate holds for Livewire updates ---
|
||||
|
||||
test('a file uploader snapshot from before the system password was set cannot register files in an unverified session', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
preg_match_all('/wire:snapshot="([^"]+)"/', $this->get(route('upload'))->assertOk()->getContent(), $matches);
|
||||
$snapshot = collect($matches[1])
|
||||
->map(fn (string $raw): array => json_decode(htmlspecialchars_decode($raw, ENT_QUOTES), true))
|
||||
->first(fn (array $snapshot): bool => str_contains($snapshot['memo']['name'] ?? '', 'file-uploader'));
|
||||
expect($snapshot)->not->toBeNull();
|
||||
|
||||
Setting::set('system_password', Hash::make('system-secret'));
|
||||
$this->flushSession();
|
||||
|
||||
$response = $this->withHeaders(['X-Livewire' => '1'])
|
||||
->postJson(app('livewire')->getUpdateUri(), [
|
||||
'components' => [[
|
||||
'snapshot' => json_encode($snapshot),
|
||||
'updates' => [],
|
||||
'calls' => [['method' => 'registerFiles', 'params' => [[['name' => 'file.bin', 'size' => 1024]]], 'path' => '']],
|
||||
]],
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('system-password'));
|
||||
expect(Share::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
// --- Share password minimum length ---
|
||||
|
||||
test('share password must be at least 8 characters', function () {
|
||||
|
||||
@@ -1,9 +1,41 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Drop the guards and the session store, so the next request authenticates from its cookie only.
|
||||
*/
|
||||
function forgetRequestState(): void
|
||||
{
|
||||
Auth::forgetGuards();
|
||||
app('session')->forgetDrivers();
|
||||
app()->forgetInstance('session.store');
|
||||
test()->flushSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the admin in on a database session, use it once as an admin would, and return its ID.
|
||||
*/
|
||||
function signedInAdminSessionId(User $admin): string
|
||||
{
|
||||
config(['session.driver' => 'database']);
|
||||
|
||||
$sessionId = test()->post(route('login.store'), ['email' => $admin->email, 'password' => 'Old-Password-1!'])
|
||||
->assertRedirect()
|
||||
->getCookie(config('session.cookie'))
|
||||
->getValue();
|
||||
|
||||
forgetRequestState();
|
||||
test()->withCookie(config('session.cookie'), $sessionId)->get(route('admin.dashboard'))->assertOk();
|
||||
forgetRequestState();
|
||||
|
||||
return $sessionId;
|
||||
}
|
||||
|
||||
test('password can be updated', function () {
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('password'),
|
||||
@@ -38,3 +70,65 @@ test('correct password must be provided to update password', function () {
|
||||
|
||||
$response->assertHasErrors(['current_password']);
|
||||
});
|
||||
|
||||
test('changing the password signs the user\'s other sessions out', function () {
|
||||
$admin = User::factory()->admin()->create(['password' => Hash::make('Old-Password-1!')]);
|
||||
$sessionId = signedInAdminSessionId($admin);
|
||||
|
||||
Livewire::actingAs($admin)->test('pages::settings.password')
|
||||
->set('current_password', 'Old-Password-1!')
|
||||
->set('password', 'New-Password-2!xY')
|
||||
->set('password_confirmation', 'New-Password-2!xY')
|
||||
->call('updatePassword')
|
||||
->assertHasNoErrors();
|
||||
forgetRequestState();
|
||||
|
||||
$this->withCookie(config('session.cookie'), $sessionId)
|
||||
->get(route('admin.dashboard'))
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('resetting the password signs the user\'s sessions out', function () {
|
||||
$admin = User::factory()->admin()->create(['password' => Hash::make('Old-Password-1!')]);
|
||||
$sessionId = signedInAdminSessionId($admin);
|
||||
|
||||
// The reset comes from a browser that is not signed in, without the session's cookie.
|
||||
$this->defaultCookies = [];
|
||||
$this->post(route('password.update'), [
|
||||
'token' => Password::broker()->createToken($admin),
|
||||
'email' => $admin->email,
|
||||
'password' => 'New-Password-3!xY',
|
||||
'password_confirmation' => 'New-Password-3!xY',
|
||||
])->assertSessionHasNoErrors();
|
||||
forgetRequestState();
|
||||
|
||||
$this->withCookie(config('session.cookie'), $sessionId)
|
||||
->get(route('admin.dashboard'))
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('the session that changes the password stays signed in', function () {
|
||||
$admin = User::factory()->admin()->create(['password' => Hash::make('Old-Password-1!')]);
|
||||
$sessionId = signedInAdminSessionId($admin);
|
||||
|
||||
preg_match('/wire:snapshot="([^"]+)"/', $this->withCookie(config('session.cookie'), $sessionId)->get(route('user-password.edit'))->assertOk()->getContent(), $matches);
|
||||
forgetRequestState();
|
||||
|
||||
$this->withCookie(config('session.cookie'), $sessionId)
|
||||
->withCredentials()
|
||||
->withHeaders(['X-Livewire' => '1'])
|
||||
->postJson(app('livewire')->getUpdateUri(), [
|
||||
'components' => [[
|
||||
'snapshot' => htmlspecialchars_decode($matches[1], ENT_QUOTES),
|
||||
'updates' => ['current_password' => 'Old-Password-1!', 'password' => 'New-Password-2!xY', 'password_confirmation' => 'New-Password-2!xY'],
|
||||
'calls' => [['method' => 'updatePassword', 'params' => [], 'path' => '']],
|
||||
]],
|
||||
])
|
||||
->assertOk();
|
||||
expect(Hash::check('New-Password-2!xY', $admin->refresh()->password))->toBeTrue();
|
||||
forgetRequestState();
|
||||
|
||||
$this->withCookie(config('session.cookie'), $sessionId)
|
||||
->get(route('admin.dashboard'))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
@@ -74,3 +74,40 @@ test('correct password must be provided to delete account', function () {
|
||||
|
||||
expect($user->fresh())->not->toBeNull();
|
||||
});
|
||||
|
||||
test('the only admin cannot delete their account', function () {
|
||||
$admin = User::query()->where('is_admin', true)->sole();
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
Livewire::test('pages::settings.delete-user-form')
|
||||
->set('password', 'password')
|
||||
->call('deleteUser')
|
||||
->assertHasErrors(['password'])
|
||||
->assertNoRedirect();
|
||||
|
||||
expect($admin->fresh())->not->toBeNull();
|
||||
expect(auth()->check())->toBeTrue();
|
||||
});
|
||||
|
||||
test('an admin can delete their account while another admin remains', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
Livewire::test('pages::settings.delete-user-form')
|
||||
->set('password', 'password')
|
||||
->call('deleteUser')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect('/');
|
||||
|
||||
expect($admin->fresh())->toBeNull();
|
||||
});
|
||||
|
||||
test('the only admin\'s account card explains why it cannot be deleted instead of offering the button', function () {
|
||||
$this->actingAs(User::query()->where('is_admin', true)->sole());
|
||||
|
||||
Livewire::test('pages::settings.delete-user-form')
|
||||
->assertSee('You are the only admin, so this account cannot be deleted.')
|
||||
->assertDontSee('data-test="delete-user-button"', false);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Livewire\SetupWizard;
|
||||
use App\Models\User;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('setup wizard renders when no admin exists', function () {
|
||||
@@ -38,6 +39,21 @@ test('setup wizard creates admin user', function () {
|
||||
expect($admin->is_admin)->toBeTrue();
|
||||
});
|
||||
|
||||
test('the setup wizard holds the admin password to the password defaults', function () {
|
||||
User::query()->where('is_admin', true)->delete();
|
||||
Password::defaults(fn () => Password::min(12)->mixedCase()->symbols());
|
||||
|
||||
Livewire::test(SetupWizard::class)
|
||||
->set('name', 'Admin User')
|
||||
->set('email', 'admin@example.com')
|
||||
->set('password', 'password123')
|
||||
->set('password_confirmation', 'password123')
|
||||
->call('createAdmin')
|
||||
->assertHasErrors(['password']);
|
||||
|
||||
expect(User::query()->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('setup wizard validates required fields', function () {
|
||||
User::query()->where('is_admin', true)->delete();
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ test('the share created page offers the link as a QR code and through the share
|
||||
$url = route('share.download', $share);
|
||||
$svg = app(QrCodeService::class)->svg($url);
|
||||
|
||||
$response = $this->get(route('share.created', $share));
|
||||
$response = $this->withSession(['created_shares' => [$share->token]])->get(route('share.created', $share));
|
||||
|
||||
$response->assertOk()
|
||||
->assertSee($url, false)
|
||||
@@ -30,7 +30,8 @@ test('the share created page offers the link as a QR code and through the share
|
||||
test('the QR code dialog reminds that a protected share also needs its password', function () {
|
||||
$share = Share::factory()->withPassword()->create();
|
||||
|
||||
$this->get(route('share.created', $share))
|
||||
$this->withSession(['created_shares' => [$share->token]])
|
||||
->get(route('share.created', $share))
|
||||
->assertOk()
|
||||
->assertSee('Recipients also need the password.');
|
||||
});
|
||||
@@ -38,7 +39,10 @@ test('the QR code dialog reminds that a protected share also needs its password'
|
||||
test('the uploader who just set the password can copy it beside the link, without it being on screen', function () {
|
||||
$share = Share::factory()->withPassword()->create();
|
||||
|
||||
$response = $this->withSession(['share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')]])
|
||||
$response = $this->withSession([
|
||||
'created_shares' => [$share->token],
|
||||
'share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')],
|
||||
])
|
||||
->get(route('share.created', $share));
|
||||
|
||||
$response->assertSee('data-test="share-password"', false)
|
||||
@@ -52,7 +56,11 @@ test('the uploader who just set the password can copy it beside the link, withou
|
||||
|
||||
test('the password is not shown without a flash for this share', function (?string $flashedFor) {
|
||||
$share = Share::factory()->withPassword()->create();
|
||||
$session = $flashedFor === null ? [] : ['share_password' => ['token' => $flashedFor, 'password' => Crypt::encryptString('violet-orbit-canyon')]];
|
||||
$session = ['created_shares' => [$share->token]];
|
||||
|
||||
if ($flashedFor !== null) {
|
||||
$session['share_password'] = ['token' => $flashedFor, 'password' => Crypt::encryptString('violet-orbit-canyon')];
|
||||
}
|
||||
|
||||
$response = $this->withSession($session)->get(route('share.created', $share));
|
||||
|
||||
@@ -75,8 +83,23 @@ test('the Private text stat shows for a share with text, and the Files stat excl
|
||||
'the secret note',
|
||||
);
|
||||
|
||||
$response = $this->get(route('share.created', $share));
|
||||
$response = $this->withSession(['created_shares' => [$share->token]])->get(route('share.created', $share));
|
||||
|
||||
$response->assertOk()
|
||||
->assertSeeInOrder(['Files', '2', 'Private text']);
|
||||
});
|
||||
|
||||
test('the share created page is not found for a session that did not create the share', function () {
|
||||
$share = Share::factory()->withPassword()->create();
|
||||
|
||||
$this->get(route('share.created', $share))->assertNotFound();
|
||||
});
|
||||
|
||||
test('the share created page is not found once the share is no longer reachable', function (Share $share) {
|
||||
$this->withSession(['created_shares' => [$share->token]])
|
||||
->get(route('share.created', $share))
|
||||
->assertNotFound();
|
||||
})->with([
|
||||
'expired' => fn () => Share::factory()->expired()->create(),
|
||||
'at its download limit' => fn () => Share::factory()->withMaxDownloads(1)->create(['download_count' => 1]),
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -31,6 +32,19 @@ test('a chunk for a pending share this session started is stored', function () {
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(1);
|
||||
});
|
||||
|
||||
test('a chunk that takes the stored bytes beyond the storage quota returns 422', function () {
|
||||
Storage::fake('shares');
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
Setting::set('max_storage_quota', 100);
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
|
||||
Share::factory()->create(['total_size' => 98]);
|
||||
|
||||
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, false), [$file->share->token]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||
});
|
||||
|
||||
test('a chunk for a pending share another session started returns 404', function () {
|
||||
Storage::fake('shares');
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
|
||||
|
||||
Reference in New Issue
Block a user