Cut over-engineering found by a repo-wide audit
docker / test (8.5) (push) Successful in 3m10s
linter / quality (push) Successful in 1m5s
tests / ci (8.5) (push) Successful in 3m9s
docker / build-and-push (push) Successful in 21m5s
docker / release (push) Skipped

- 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:
surtic86
2026-09-18 23:14:33 +02:00
co-authored by Claude Opus 5
parent a62edbcefb
commit 4530298398
57 changed files with 240 additions and 2784 deletions
-6
View File
@@ -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());
+17
View File
@@ -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);
});
+15
View File
@@ -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');
-1
View File
@@ -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'))],
+2 -3
View File
@@ -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 () {
+1 -2
View File
@@ -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
View File
@@ -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));
}
+14 -21
View File
@@ -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');