Cut over-engineering found by a repo-wide audit
- Config: auth, services, logging, queue and database only repeated the
framework's own files and are gone; the others keep only the keys that
differ (app version, cache serializable_classes, session cookie name,
Markdown mail theme, the shares disk, three Octane values, Livewire's
pagination theme and payload guards).
- Email verification is removed: User never implemented MustVerifyEmail,
so it was never enforced, and SealShare has a single admin and no
registration. CreateNewUser goes with it.
- FileEncryptionService::encryptFile() and generateSalt() were only used
by tests; tests build files with encryptTestFile() in tests/Pest.php.
- The expiration options are defined once, as Share::EXPIRATIONS. "30 Days"
now lasts 30 days instead of a calendar month, and Admin settings only
save a default expiration that is one of the options.
- One-caller helpers are inlined, the uploader reads chunk responses with
XHR's responseType, and starter-kit leftovers are removed.
- Docker: PHP reads the PHP_* limits from the environment itself
(${VAR:-default} in uploads.ini); both entrypoints stop writing the ini.
docker-compose.yml shares the app and scheduler variables through one
anchor. The dev image installs gd for the screenshot publisher and fake
test images.
- Development runs in Docker only: the composer dev script, concurrently,
laravel/pail, laravel/sail, autoprefixer and the shell-quote override
are gone.
- phpunit.xml forces the test environment with <server> entries, so tests
run in the dev container no longer use its real database.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a62edbcefb
commit
4530298398
@@ -171,12 +171,6 @@ test('the two-factor challenge page holds at every breakpoint', function () {
|
||||
walkBreakpoints(ready(visit(route('two-factor.login', [], false))), 'button[type="submit"]');
|
||||
});
|
||||
|
||||
test('the email verification prompt holds at every breakpoint', function () {
|
||||
$this->actingAs(User::factory()->unverified()->create());
|
||||
|
||||
walkBreakpoints(ready(visit(route('verification.notice', [], false))));
|
||||
});
|
||||
|
||||
test('the password confirmation page holds at every breakpoint', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
|
||||
@@ -51,6 +51,23 @@ test('admin can save settings', function () {
|
||||
expect(Setting::get('default_expiration'))->toBe('7d');
|
||||
});
|
||||
|
||||
test('the default expiration only accepts the offered options', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
|
||||
$component = Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->set('defaultExpiration', '99y')
|
||||
->call('saveSettings')
|
||||
->assertHasErrors(['defaultExpiration' => 'in']);
|
||||
|
||||
expect(Setting::get('default_expiration'))->toBeNull();
|
||||
|
||||
$component
|
||||
->set('defaultExpiration', '')
|
||||
->call('saveSettings')
|
||||
->assertHasNoErrors();
|
||||
});
|
||||
|
||||
test('admin can set system password', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
Livewire::actingAs($admin)
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
test('email verification screen can be rendered', function () {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get(route('verification.notice'));
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('email can be verified', function () {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
Event::fake();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
Event::assertDispatched(Verified::class);
|
||||
|
||||
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
|
||||
$response->assertRedirect(route('admin.dashboard', absolute: false).'?verified=1');
|
||||
});
|
||||
|
||||
test('email is not verified with invalid hash', function () {
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1('wrong-email')]
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
|
||||
});
|
||||
|
||||
test('already verified user visiting verification link is redirected without firing event again', function () {
|
||||
$user = User::factory()->create([
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
Event::fake();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl)
|
||||
->assertRedirect(route('admin.dashboard', absolute: false).'?verified=1');
|
||||
|
||||
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
|
||||
Event::assertNotDispatched(Verified::class);
|
||||
});
|
||||
@@ -249,6 +249,21 @@ test('file upload with expiration sets expires_at', function () {
|
||||
expect($share->expires_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a 30 day expiration lasts 30 days, not a calendar month', function () {
|
||||
Storage::fake('shares');
|
||||
$this->travelTo(new DateTimeImmutable('2026-02-01 12:00:00'));
|
||||
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
uploadThroughPage($component, ['file.txt' => 'content']);
|
||||
|
||||
$component
|
||||
->set('expiration', '30d')
|
||||
->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
|
||||
expect(Share::query()->first()->expires_at->toDateTimeString())->toBe('2026-03-03 12:00:00');
|
||||
});
|
||||
|
||||
test('file upload with max downloads sets limit', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ test('every page is one page template, one h1 and the same width', function (Clo
|
||||
|
||||
return $test->get(route('system-password'));
|
||||
}],
|
||||
'verify email' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->unverified()->create())->get(route('verification.notice'))],
|
||||
'confirm password' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('password.confirm'))],
|
||||
'profile' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('profile.edit'))],
|
||||
'password' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('user-password.edit'))],
|
||||
|
||||
@@ -27,10 +27,9 @@ test('profile information can be updated', function () {
|
||||
|
||||
expect($user->name)->toEqual('Test User');
|
||||
expect($user->email)->toEqual('test@example.com');
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('email verification status is unchanged when email address is unchanged', function () {
|
||||
test('the profile saves with its own unchanged email address', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
@@ -42,7 +41,7 @@ test('email verification status is unchanged when email address is unchanged', f
|
||||
|
||||
$response->assertHasNoErrors();
|
||||
|
||||
expect($user->refresh()->email_verified_at)->not->toBeNull();
|
||||
expect($user->refresh()->name)->toEqual('Test User');
|
||||
});
|
||||
|
||||
test('user can delete their account', function () {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
use App\Livewire\ShareDownload;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -280,7 +279,7 @@ test('a password share created before key wrapping still unlocks and downloads',
|
||||
$source = tempnam(sys_get_temp_dir(), 'old');
|
||||
file_put_contents($source, 'old content');
|
||||
Storage::disk('shares')->makeDirectory($share->token);
|
||||
app(FileEncryptionService::class)->encryptFile($source, Storage::disk('shares')->path($share->token.'/old.enc'), bin2hex(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 32, true)), 1024);
|
||||
encryptTestFile($source, Storage::disk('shares')->path($share->token.'/old.enc'), bin2hex(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 32, true)), 1024);
|
||||
unlink($source);
|
||||
|
||||
Livewire::test(ShareDownload::class, ['share' => $share])
|
||||
|
||||
+19
-20
@@ -26,21 +26,6 @@ pest()->extend(TestCase::class)
|
||||
})
|
||||
->in('Feature', 'Browser', 'Screenshots');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expectations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When you're writing tests, you often need to check that values meet certain conditions. The
|
||||
| "expect()" function gives you access to a set of "expectations" methods that you can use
|
||||
| to assert different things. Of course, you may extend the Expectation API at any time.
|
||||
|
|
||||
*/
|
||||
|
||||
expect()->extend('toBeOne', function () {
|
||||
return $this->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Functions
|
||||
@@ -52,11 +37,6 @@ expect()->extend('toBeOne', function () {
|
||||
|
|
||||
*/
|
||||
|
||||
function something()
|
||||
{
|
||||
// ..
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of SealShare, once it can be used: loaded, with Alpine and Livewire started. Shared by
|
||||
* every file under tests/Browser, so a browser test needs no visit() of its own to define it.
|
||||
@@ -77,3 +57,22 @@ function encryptedChunk(ShareFile $file, string $plaintext, int $index, bool $is
|
||||
|
||||
return $encryption->encryptChunk($plaintext, $file->share->encryption_key, $header['noncePrefix'], $index, $isLast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a whole file in the SEALCHK2 format, as the uploader's browser does chunk by chunk.
|
||||
*/
|
||||
function encryptTestFile(string $sourcePath, string $destinationPath, string $key, int $chunkSize): void
|
||||
{
|
||||
$encryption = new FileEncryptionService;
|
||||
$header = $encryption->createHeader($chunkSize);
|
||||
$noncePrefix = $encryption->parseHeader($header)['noncePrefix'];
|
||||
$plaintext = (string) file_get_contents($sourcePath);
|
||||
$chunkCount = $encryption->chunkCount(strlen($plaintext), $chunkSize);
|
||||
|
||||
$chunks = array_map(
|
||||
fn (int $index): string => $encryption->encryptChunk(substr($plaintext, $index * $chunkSize, $chunkSize), $key, $noncePrefix, $index, $index === $chunkCount - 1),
|
||||
range(0, $chunkCount - 1),
|
||||
);
|
||||
|
||||
file_put_contents($destinationPath, $header.implode('', $chunks));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ test('encrypt and decrypt round-trip works', function () {
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1024);
|
||||
encryptTestFile($sourcePath, $encryptedPath, $key, 1024);
|
||||
|
||||
expect(file_get_contents($encryptedPath))->not->toContain($content);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
@@ -57,14 +57,14 @@ test('decrypt with wrong key fails', function () {
|
||||
|
||||
file_put_contents($sourcePath, 'Secret data');
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
|
||||
encryptTestFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
|
||||
|
||||
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
test('derive key produces consistent results', function () {
|
||||
$password = 'my-secure-password';
|
||||
$salt = $this->service->generateSalt();
|
||||
$salt = bin2hex(random_bytes(32));
|
||||
|
||||
$key1 = $this->service->deriveKey($password, $salt);
|
||||
$key2 = $this->service->deriveKey($password, $salt);
|
||||
@@ -73,7 +73,7 @@ test('derive key produces consistent results', function () {
|
||||
});
|
||||
|
||||
test('derive key with different passwords produces different keys', function () {
|
||||
$salt = $this->service->generateSalt();
|
||||
$salt = bin2hex(random_bytes(32));
|
||||
|
||||
$key1 = $this->service->deriveKey('password1', $salt);
|
||||
$key2 = $this->service->deriveKey('password2', $salt);
|
||||
@@ -84,8 +84,8 @@ test('derive key with different passwords produces different keys', function ()
|
||||
test('derive key with different salts produces different keys', function () {
|
||||
$password = 'same-password';
|
||||
|
||||
$key1 = $this->service->deriveKey($password, $this->service->generateSalt());
|
||||
$key2 = $this->service->deriveKey($password, $this->service->generateSalt());
|
||||
$key1 = $this->service->deriveKey($password, bin2hex(random_bytes(32)));
|
||||
$key2 = $this->service->deriveKey($password, bin2hex(random_bytes(32)));
|
||||
|
||||
expect($key1)->not->toBe($key2);
|
||||
});
|
||||
@@ -97,13 +97,6 @@ test('generate random key returns 64 char hex string', function () {
|
||||
expect(ctype_xdigit($key))->toBeTrue();
|
||||
});
|
||||
|
||||
test('generate salt returns 64 char hex string', function () {
|
||||
$salt = $this->service->generateSalt();
|
||||
|
||||
expect(strlen($salt))->toBe(64);
|
||||
expect(ctype_xdigit($salt))->toBeTrue();
|
||||
});
|
||||
|
||||
test('password-derived key encrypt/decrypt round-trip works', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
@@ -111,9 +104,9 @@ test('password-derived key encrypt/decrypt round-trip works', function () {
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = bin2hex($this->service->deriveKey('user-password', $this->service->generateSalt()));
|
||||
$key = bin2hex($this->service->deriveKey('user-password', bin2hex(random_bytes(32))));
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1024);
|
||||
encryptTestFile($sourcePath, $encryptedPath, $key, 1024);
|
||||
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
});
|
||||
@@ -124,7 +117,7 @@ test('an encrypted file starts with the SEALCHK2 header and its chunk size', fun
|
||||
|
||||
file_put_contents($sourcePath, 'test content');
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
|
||||
encryptTestFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
|
||||
|
||||
expect(file_get_contents($encryptedPath, false, null, 0, 12))->toBe('SEALCHK2'.pack('N', 1024));
|
||||
});
|
||||
@@ -137,7 +130,7 @@ test('multi-chunk round-trip works', function () {
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
encryptTestFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
|
||||
expect(filesize($encryptedPath))->toBe(19 + 3 * 16 + 2500);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
@@ -151,7 +144,7 @@ test('exact chunk boundary round-trip works', function () {
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
encryptTestFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
|
||||
expect(filesize($encryptedPath))->toBe(19 + 2 * 16 + 2000);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
@@ -164,7 +157,7 @@ test('empty file round-trip works', function () {
|
||||
file_put_contents($sourcePath, '');
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
encryptTestFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
|
||||
expect(filesize($encryptedPath))->toBe(19 + 16);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe('');
|
||||
@@ -197,7 +190,7 @@ test('a file cut short at a chunk boundary fails to decrypt', function () {
|
||||
file_put_contents($sourcePath, random_bytes(3000));
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
encryptTestFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
|
||||
$handle = fopen($encryptedPath, 'r+b');
|
||||
ftruncate($handle, 19 + 2 * (1000 + 16));
|
||||
@@ -265,7 +258,7 @@ test('wrong key on chunked file throws exception', function () {
|
||||
|
||||
file_put_contents($sourcePath, random_bytes(2500));
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1000);
|
||||
encryptTestFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1000);
|
||||
|
||||
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
Reference in New Issue
Block a user