snapshot of https://github.com/krayin/laravel-crm.git (2.2, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Admin | Playwright Tests / Admin | Playwright Tests | Shard 1 Of 6 (push) Failing after 37s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 2 Of 6 (push) Failing after 7s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 4 Of 6 (push) Failing after 7s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 6 Of 6 (push) Failing after 6s
Linting Tests / PHP 8.3 test on ubuntu-latest (push) Failing after 5s
CI / PHP 8.3 test on ubuntu-latest (push) Failing after 6s
Skills Consistency / validate-skills (push) Successful in 10s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 3 Of 6 (push) Failing after 7s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 5 Of 6 (push) Failing after 6s
Some checks failed
Admin | Playwright Tests / Admin | Playwright Tests | Shard 1 Of 6 (push) Failing after 37s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 2 Of 6 (push) Failing after 7s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 4 Of 6 (push) Failing after 7s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 6 Of 6 (push) Failing after 6s
Linting Tests / PHP 8.3 test on ubuntu-latest (push) Failing after 5s
CI / PHP 8.3 test on ubuntu-latest (push) Failing after 6s
Skills Consistency / validate-skills (push) Successful in 10s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 3 Of 6 (push) Failing after 7s
Admin | Playwright Tests / Admin | Playwright Tests | Shard 5 Of 6 (push) Failing after 6s
This commit is contained in:
28
tests/Feature/AuthenticationTest.php
Normal file
28
tests/Feature/AuthenticationTest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
it('can see the admin login page', function () {
|
||||
test()->get(route('admin.session.create'))
|
||||
->assertOK();
|
||||
});
|
||||
|
||||
it('can see the dashboard page after login', function () {
|
||||
$admin = getDefaultAdmin();
|
||||
|
||||
test()->actingAs($admin)
|
||||
->get(route('admin.dashboard.index'))
|
||||
->assertOK();
|
||||
|
||||
expect(auth()->guard('user')->user()->name)->toBe($admin->name);
|
||||
});
|
||||
|
||||
it('can logout from the admin panel', function () {
|
||||
$admin = getDefaultAdmin();
|
||||
|
||||
test()->actingAs($admin)
|
||||
->delete(route('admin.session.destroy'), [
|
||||
'_token' => csrf_token(),
|
||||
])
|
||||
->assertStatus(302);
|
||||
|
||||
expect(auth()->guard('user')->user())->toBeNull();
|
||||
});
|
||||
16
tests/Feature/HelpTest.php
Normal file
16
tests/Feature/HelpTest.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
it('shows the help page to an authenticated admin', function () {
|
||||
$admin = getDefaultAdmin();
|
||||
|
||||
test()->actingAs($admin)
|
||||
->get(route('admin.help.index'))
|
||||
->assertOk()
|
||||
->assertSee('Help & Resources')
|
||||
->assertSee('Cloud Hosting')
|
||||
->assertSee('Extensions')
|
||||
->assertSee('krayincrm.com/cloud-hosting')
|
||||
->assertSee('Still need a hand?')
|
||||
->assertSee('Community Forums')
|
||||
->assertSee('Video Tutorials');
|
||||
});
|
||||
145
tests/Feature/Security/AuthenticationHardeningTest.php
Normal file
145
tests/Feature/Security/AuthenticationHardeningTest.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Webkul\User\Models\Role;
|
||||
use Webkul\User\Models\User;
|
||||
|
||||
/**
|
||||
* Hardening around the admin authentication endpoints.
|
||||
*
|
||||
* Every account these tests need is created by the test itself, inside a transaction that is rolled
|
||||
* back afterwards. Nothing here assumes the database has been seeded, so the suite behaves the same
|
||||
* against a freshly migrated test database as it does against a working one.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* A user with a role that grants everything, so the ACL middleware lets the request through.
|
||||
*/
|
||||
function makeAuthProbeUser(string $password = 'correct-horse-battery-staple', int $status = 1): User
|
||||
{
|
||||
$role = Role::create([
|
||||
'name' => 'Auth Probe Role '.bin2hex(random_bytes(6)),
|
||||
'description' => 'Created by the authentication hardening tests.',
|
||||
'permission_type' => 'all',
|
||||
]);
|
||||
|
||||
return User::create([
|
||||
'name' => 'Auth Probe',
|
||||
'email' => 'auth-probe-'.bin2hex(random_bytes(6)).'@example.invalid',
|
||||
'password' => bcrypt($password),
|
||||
'status' => $status,
|
||||
'role_id' => $role->id,
|
||||
'view_permission' => 'global',
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
/**
|
||||
* The configured APP_URL carries a subdirectory, which the test client would otherwise prepend
|
||||
* to every request path, so no admin route would match.
|
||||
*/
|
||||
URL::forceRootUrl('http://localhost');
|
||||
|
||||
/**
|
||||
* Rate limiter state lives in the cache; phpunit.xml pins CACHE_STORE=array, so flushing here
|
||||
* isolates each test without touching any real cache.
|
||||
*/
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('rate limits repeated failed logins', function () {
|
||||
$credentials = [
|
||||
'email' => 'no-such-user@example.invalid',
|
||||
'password' => 'wrong-password',
|
||||
];
|
||||
|
||||
for ($attempt = 1; $attempt <= 5; $attempt++) {
|
||||
expect(test()->post('/admin/login', $credentials)->getStatusCode())
|
||||
->not->toBe(429, "attempt {$attempt} should not yet be throttled");
|
||||
}
|
||||
|
||||
test()->post('/admin/login', $credentials)->assertStatus(429);
|
||||
});
|
||||
|
||||
it('rate limits repeated forgot password requests', function () {
|
||||
$payload = ['email' => 'no-such-user@example.invalid'];
|
||||
|
||||
for ($attempt = 1; $attempt <= 5; $attempt++) {
|
||||
test()->post('/admin/forget-password', $payload);
|
||||
}
|
||||
|
||||
test()->post('/admin/forget-password', $payload)->assertStatus(429);
|
||||
});
|
||||
|
||||
it('does not reveal whether an email belongs to an account', function () {
|
||||
$response = test()->post('/admin/forget-password', [
|
||||
'email' => 'definitely-not-registered@example.invalid',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors();
|
||||
|
||||
expect(session('success'))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('destroys the whole session on logout, not just the auth state', function () {
|
||||
$admin = makeAuthProbeUser();
|
||||
|
||||
$response = test()
|
||||
->actingAs($admin, 'user')
|
||||
->withSession(['canary' => 'survives-only-if-session-is-not-invalidated'])
|
||||
->delete('/admin/logout');
|
||||
|
||||
$response->assertStatus(302);
|
||||
$response->assertSessionMissing('canary');
|
||||
|
||||
expect(auth()->guard('user')->check())->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* Only *failed* sign-ins may count against the limit.
|
||||
*
|
||||
* Throttling every attempt also throttles people who sign in correctly — the browser test suite
|
||||
* signs in once per test case from a single address, and several staff can share one office
|
||||
* address — while adding nothing against password guessing, which is made up entirely of failures.
|
||||
*/
|
||||
it('never throttles repeated successful logins', function () {
|
||||
$password = 'correct-horse-battery-staple';
|
||||
|
||||
$user = makeAuthProbeUser($password);
|
||||
|
||||
foreach (range(1, 12) as $attempt) {
|
||||
$status = test()
|
||||
->post('/admin/login', ['email' => $user->email, 'password' => $password])
|
||||
->getStatusCode();
|
||||
|
||||
expect($status)->not->toBe(429, "successful login {$attempt} was throttled");
|
||||
|
||||
auth()->guard('user')->logout();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a correct sign-in clear earlier failures for those credentials', function () {
|
||||
$password = 'correct-horse-battery-staple';
|
||||
|
||||
$user = makeAuthProbeUser($password);
|
||||
|
||||
foreach (range(1, 4) as $ignored) {
|
||||
test()->post('/admin/login', ['email' => $user->email, 'password' => 'wrong']);
|
||||
}
|
||||
|
||||
test()->post('/admin/login', ['email' => $user->email, 'password' => $password]);
|
||||
|
||||
auth()->guard('user')->logout();
|
||||
|
||||
/**
|
||||
* The budget was reset by the successful sign-in, so a fresh run of failures is needed again
|
||||
* before the limit trips.
|
||||
*/
|
||||
foreach (range(1, 4) as $attempt) {
|
||||
expect(test()->post('/admin/login', ['email' => $user->email, 'password' => 'wrong'])->getStatusCode())
|
||||
->not->toBe(429, "failure {$attempt} after a successful sign-in should not be throttled");
|
||||
}
|
||||
});
|
||||
50
tests/Feature/Security/HtmlSanitizationTest.php
Normal file
50
tests/Feature/Security/HtmlSanitizationTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Webkul\Core\Traits\Sanitizer;
|
||||
|
||||
/**
|
||||
* Stored XSS via the footer configuration (TinyMCE sanitization bypass).
|
||||
*
|
||||
* The footer label is rendered with `{!! !!}`, so the server-side sanitizer is the only thing
|
||||
* standing between a crafted POST to the configuration endpoint and script running in every
|
||||
* admin's browser. Client-side TinyMCE filtering is bypassed by posting directly.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->sanitizer = new class
|
||||
{
|
||||
use Sanitizer;
|
||||
};
|
||||
});
|
||||
|
||||
it('strips the advisory payload from footer html', function () {
|
||||
$output = $this->sanitizer->sanitizeHtml('<img src=x onerror=alert(document.domain)>');
|
||||
|
||||
expect($output)->not->toContain('onerror')
|
||||
->and($output)->not->toContain('alert(');
|
||||
});
|
||||
|
||||
it('strips script elements and javascript uris', function (string $payload, string $forbidden) {
|
||||
expect($this->sanitizer->sanitizeHtml($payload))->not->toContain($forbidden);
|
||||
})->with([
|
||||
['<script>alert(1)</script>', '<script'],
|
||||
['<a href="javascript:alert(1)">x</a>', 'javascript:'],
|
||||
['<svg onload=alert(1)></svg>', 'onload'],
|
||||
['<iframe src="//evil.test"></iframe>', '<iframe'],
|
||||
['<body onload=alert(1)>', 'onload'],
|
||||
]);
|
||||
|
||||
it('keeps legitimate formatting and target blank links intact', function () {
|
||||
$output = $this->sanitizer->sanitizeHtml(
|
||||
'<p>Powered by <a href="https://krayincrm.com" target="_blank">Krayin</a></p>'
|
||||
);
|
||||
|
||||
expect($output)->toContain('<p>')
|
||||
->and($output)->toContain('href="https://krayincrm.com"')
|
||||
->and($output)->toContain('target="_blank"')
|
||||
->and($output)->toContain('Krayin');
|
||||
});
|
||||
|
||||
it('returns an empty string for null and blank input', function () {
|
||||
expect($this->sanitizer->sanitizeHtml(null))->toBe('')
|
||||
->and($this->sanitizer->sanitizeHtml(' '))->toBe('');
|
||||
});
|
||||
204
tests/Feature/Security/InstallerLockdownTest.php
Normal file
204
tests/Feature/Security/InstallerLockdownTest.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Webkul\Installer\Helpers\DatabaseManager;
|
||||
use Webkul\Installer\Http\Middleware\CanInstall;
|
||||
|
||||
/**
|
||||
* The installer API endpoints overwrite the administrator account and re-run `migrate:fresh`
|
||||
* against live data, so they must be unreachable once the application is installed.
|
||||
*
|
||||
* The guard used to rest solely on `storage/installed`. That file is gitignored, so a fresh
|
||||
* checkout, a container rebuild, or any deploy that does not carry `storage/` across arrives
|
||||
* without it while the database is still fully populated — which reopened the endpoints to
|
||||
* anonymous callers.
|
||||
*
|
||||
* These tests point the application at a throwaway storage path, so the real marker file is never
|
||||
* created, moved or deleted, and stub the database so no query runs.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->originalStoragePath = app()->storagePath();
|
||||
|
||||
$this->tempStorage = sys_get_temp_dir().'/krayin-installer-test-'.bin2hex(random_bytes(6));
|
||||
|
||||
mkdir($this->tempStorage, 0777, true);
|
||||
|
||||
app()->useStoragePath($this->tempStorage);
|
||||
|
||||
/**
|
||||
* $completed — whether the completion flag has been recorded in the database.
|
||||
* $populated — whether the database actually holds an installed application.
|
||||
*/
|
||||
$this->fakeDatabase = function (bool $completed, bool $populated = false) {
|
||||
app()->instance(DatabaseManager::class, new class($completed, $populated) extends DatabaseManager
|
||||
{
|
||||
public bool $backfilled = false;
|
||||
|
||||
public function __construct(private bool $completed, private bool $populated) {}
|
||||
|
||||
public function isInstallationCompleted(): bool
|
||||
{
|
||||
return $this->completed;
|
||||
}
|
||||
|
||||
public function isInstalled()
|
||||
{
|
||||
return $this->populated;
|
||||
}
|
||||
|
||||
public function markInstallationCompleted(): void
|
||||
{
|
||||
$this->backfilled = true;
|
||||
$this->completed = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
app()->useStoragePath($this->originalStoragePath);
|
||||
|
||||
@unlink($this->tempStorage.'/installed');
|
||||
@unlink($this->tempStorage.'/installing');
|
||||
@rmdir($this->tempStorage);
|
||||
});
|
||||
|
||||
it('treats the application as installed when the marker file is present', function () {
|
||||
($this->fakeDatabase)(false);
|
||||
|
||||
touch(storage_path('installed'));
|
||||
|
||||
expect((new CanInstall)->isInstallationComplete())->toBeTrue();
|
||||
});
|
||||
|
||||
it('treats the application as installed when the marker is gone but the database says so', function () {
|
||||
($this->fakeDatabase)(true);
|
||||
|
||||
expect(file_exists(storage_path('installed')))->toBeFalse()
|
||||
->and((new CanInstall)->isInstallationComplete())->toBeTrue();
|
||||
});
|
||||
|
||||
it('still allows a genuine installation to proceed', function () {
|
||||
($this->fakeDatabase)(false);
|
||||
|
||||
expect(file_exists(storage_path('installed')))->toBeFalse()
|
||||
->and((new CanInstall)->isInstallationComplete())->toBeFalse();
|
||||
});
|
||||
|
||||
it('blocks the installer api on an installed app that lost its marker file', function (string $path) {
|
||||
($this->fakeDatabase)(true);
|
||||
|
||||
$reached = false;
|
||||
|
||||
$response = (new CanInstall)->handle(
|
||||
Request::create($path, 'POST'),
|
||||
function () use (&$reached) {
|
||||
$reached = true;
|
||||
|
||||
return response('controller reached');
|
||||
}
|
||||
);
|
||||
|
||||
expect($reached)->toBeFalse()
|
||||
->and($response->getStatusCode())->toBe(302);
|
||||
})->with([
|
||||
'/install/api/admin-config-setup',
|
||||
'/install/api/run-migration',
|
||||
'/install/api/run-seeder',
|
||||
'/install/api/env-file-setup',
|
||||
]);
|
||||
|
||||
it('lets the installer api through while an installation is genuinely in progress', function () {
|
||||
($this->fakeDatabase)(false);
|
||||
|
||||
$reached = false;
|
||||
|
||||
(new CanInstall)->handle(
|
||||
Request::create('/install/api/run-migration', 'POST'),
|
||||
function () use (&$reached) {
|
||||
$reached = true;
|
||||
|
||||
return response('ok');
|
||||
}
|
||||
);
|
||||
|
||||
expect($reached)->toBeTrue();
|
||||
});
|
||||
|
||||
/**
|
||||
* A legacy installation — one completed before the database flag existed — has neither signal
|
||||
* after a deploy that leaves `storage/` behind, yet its database is fully populated. The installer
|
||||
* API must still be closed to it: reaching run-migration there wipes live data, and
|
||||
* admin-config-setup overwrites the administrator account.
|
||||
*/
|
||||
it('treats a legacy installation with no flag and no marker as installed', function () {
|
||||
($this->fakeDatabase)(completed: false, populated: true);
|
||||
|
||||
expect(file_exists(storage_path('installed')))->toBeFalse()
|
||||
->and((new CanInstall)->isInstallationComplete())->toBeTrue();
|
||||
});
|
||||
|
||||
it('blocks the installer api for a legacy installation that lost its marker file', function (string $path) {
|
||||
($this->fakeDatabase)(completed: false, populated: true);
|
||||
|
||||
$reached = false;
|
||||
|
||||
(new CanInstall)->handle(Request::create($path, 'POST'), function () use (&$reached) {
|
||||
$reached = true;
|
||||
|
||||
return response('controller reached');
|
||||
});
|
||||
|
||||
expect($reached)->toBeFalse();
|
||||
})->with([
|
||||
'/install/api/run-migration',
|
||||
'/install/api/admin-config-setup',
|
||||
'/install/api/env-file-setup',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The seeder creates the default administrator before `admin-config-setup` runs, so from that
|
||||
* moment the database looks "installed" while the installation is in fact still going. The
|
||||
* in-progress marker is what keeps the final step reachable.
|
||||
*/
|
||||
it('does not lock out the final installer step once the seeder has run', function () {
|
||||
($this->fakeDatabase)(completed: false, populated: true);
|
||||
|
||||
app(DatabaseManager::class)->markInstallationInProgress();
|
||||
|
||||
expect(app(DatabaseManager::class)->isInstallationComplete())->toBeFalse();
|
||||
|
||||
$reached = false;
|
||||
|
||||
(new CanInstall)->handle(Request::create('/install/api/admin-config-setup', 'POST'), function () use (&$reached) {
|
||||
$reached = true;
|
||||
|
||||
return response('ok');
|
||||
});
|
||||
|
||||
expect($reached)->toBeTrue();
|
||||
});
|
||||
|
||||
it('closes the installer again once the in-progress marker is cleared', function () {
|
||||
($this->fakeDatabase)(completed: false, populated: true);
|
||||
|
||||
$database = app(DatabaseManager::class);
|
||||
|
||||
$database->markInstallationInProgress();
|
||||
expect($database->isInstallationComplete())->toBeFalse();
|
||||
|
||||
$database->clearInstallationInProgress();
|
||||
expect($database->isInstallationComplete())->toBeTrue();
|
||||
});
|
||||
|
||||
it('backfills the database flag when it infers completion from the database', function () {
|
||||
($this->fakeDatabase)(completed: false, populated: true);
|
||||
|
||||
$database = app(DatabaseManager::class);
|
||||
|
||||
expect($database->backfilled)->toBeFalse();
|
||||
|
||||
$database->isInstallationComplete();
|
||||
|
||||
expect($database->backfilled)->toBeTrue();
|
||||
});
|
||||
81
tests/Feature/Security/PasswordResetStatusTest.php
Normal file
81
tests/Feature/Security/PasswordResetStatusTest.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Webkul\Admin\Http\Controllers\User\ResetPasswordController;
|
||||
use Webkul\User\Models\Role;
|
||||
use Webkul\User\Models\User;
|
||||
|
||||
/**
|
||||
* Completing a password reset must not sign in a deactivated account.
|
||||
*
|
||||
* Login refuses `status == 0` and logs the user straight back out, so resetting a password must
|
||||
* not become the way around that check.
|
||||
*
|
||||
* Every test here runs inside a transaction that is rolled back afterwards, so the temporary
|
||||
* accounts never persist in the database, and each creates the role it needs rather than assuming
|
||||
* the database has been seeded.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* Invoke the controller's protected resetPassword() the way the password broker does.
|
||||
*/
|
||||
function completeResetFor(User $user): void
|
||||
{
|
||||
$controller = app(ResetPasswordController::class);
|
||||
|
||||
$method = (new ReflectionClass($controller))->getMethod('resetPassword');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($controller, $user, 'brand-new-password');
|
||||
}
|
||||
|
||||
function makeUser(int $status): User
|
||||
{
|
||||
/**
|
||||
* Created here rather than assumed: a freshly migrated test database has no roles, and the
|
||||
* users table has a foreign key onto them.
|
||||
*/
|
||||
$role = Role::create([
|
||||
'name' => 'Reset Probe Role '.bin2hex(random_bytes(6)),
|
||||
'description' => 'Created by the password reset status tests.',
|
||||
'permission_type' => 'all',
|
||||
]);
|
||||
|
||||
return User::create([
|
||||
'name' => 'Reset Status Probe',
|
||||
'email' => 'reset-probe-'.bin2hex(random_bytes(6)).'@example.invalid',
|
||||
'password' => bcrypt('original-password'),
|
||||
'status' => $status,
|
||||
'role_id' => $role->id,
|
||||
'view_permission' => 'global',
|
||||
]);
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
auth()->guard('user')->logout();
|
||||
});
|
||||
|
||||
it('does not sign in a deactivated account after a password reset', function () {
|
||||
$user = makeUser(status: 0);
|
||||
|
||||
completeResetFor($user);
|
||||
|
||||
expect(auth()->guard('user')->check())->toBeFalse();
|
||||
});
|
||||
|
||||
it('still signs in an active account after a password reset', function () {
|
||||
$user = makeUser(status: 1);
|
||||
|
||||
completeResetFor($user);
|
||||
|
||||
expect(auth()->guard('user')->check())->toBeTrue()
|
||||
->and(auth()->guard('user')->id())->toBe($user->id);
|
||||
});
|
||||
|
||||
it('changes the password for a deactivated account even though it does not sign in', function () {
|
||||
$user = makeUser(status: 0);
|
||||
|
||||
completeResetFor($user);
|
||||
|
||||
expect(Hash::check('brand-new-password', $user->fresh()->password))->toBeTrue();
|
||||
});
|
||||
88
tests/Feature/Security/SvgUploadSanitizationTest.php
Normal file
88
tests/Feature/Security/SvgUploadSanitizationTest.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Webkul\Core\Traits\Sanitizer;
|
||||
|
||||
/**
|
||||
* Stored XSS via the SVG sanitizer being skipped for a mis-named upload.
|
||||
*
|
||||
* The TinyMCE media upload derives the *stored* extension from the file's own content
|
||||
* (`UploadedFile::extension()`), so SVG bytes named `payload.png` are stored — and later served —
|
||||
* as an SVG. A sanitizer that decides what to clean from the client-supplied name therefore never
|
||||
* runs on exactly the file that needs it.
|
||||
*/
|
||||
const MALICIOUS_SVG = <<<'SVG'
|
||||
<?xml version="1.0"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<script>alert(document.domain)</script>
|
||||
<a xlink:href="javascript:alert(1)"><text x="0" y="20">click</text></a>
|
||||
<rect width="100" height="100" onload="alert(1)" />
|
||||
</svg>
|
||||
SVG;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake();
|
||||
|
||||
$this->sanitizer = new class
|
||||
{
|
||||
use Sanitizer;
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Store the payload the way the upload handlers do, then sanitize it.
|
||||
*/
|
||||
function storeThenSanitize(object $sanitizer, string $storedPath, string $clientName): string
|
||||
{
|
||||
Storage::put($storedPath, MALICIOUS_SVG);
|
||||
|
||||
$sanitizer->sanitizeSvg(
|
||||
$storedPath,
|
||||
UploadedFile::fake()->createWithContent($clientName, MALICIOUS_SVG)
|
||||
);
|
||||
|
||||
return (string) Storage::get($storedPath);
|
||||
}
|
||||
|
||||
it('sanitizes an svg uploaded under a non-svg name', function () {
|
||||
$result = storeThenSanitize($this->sanitizer, 'tinymce/abc123.svg', 'payload.png');
|
||||
|
||||
expect($result)->not->toContain('<script')
|
||||
->and($result)->not->toContain('onload')
|
||||
->and($result)->not->toContain('javascript:');
|
||||
});
|
||||
|
||||
it('still sanitizes an svg named honestly', function () {
|
||||
$result = storeThenSanitize($this->sanitizer, 'tinymce/abc123.svg', 'logo.svg');
|
||||
|
||||
expect($result)->not->toContain('<script')
|
||||
->and($result)->not->toContain('onload');
|
||||
});
|
||||
|
||||
it('sanitizes svg markup even when neither the name nor the stored path says svg', function () {
|
||||
$result = storeThenSanitize($this->sanitizer, 'configuration/abc123.png', 'payload.png');
|
||||
|
||||
expect($result)->not->toContain('<script')
|
||||
->and($result)->not->toContain('onload');
|
||||
});
|
||||
|
||||
it('detects svg by stored path and by markup, not only by client name', function () {
|
||||
expect($this->sanitizer->isSvgPath('tinymce/abc.svg'))->toBeTrue()
|
||||
->and($this->sanitizer->isSvgPath('tinymce/abc.png'))->toBeFalse()
|
||||
->and($this->sanitizer->hasSvgMarkup(MALICIOUS_SVG))->toBeTrue()
|
||||
->and($this->sanitizer->hasSvgMarkup('just plain text'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('leaves a genuine non-svg upload untouched', function () {
|
||||
$png = "\x89PNG\r\n\x1a\n".str_repeat("\x00", 32);
|
||||
|
||||
Storage::put('tinymce/real.png', $png);
|
||||
|
||||
$this->sanitizer->sanitizeSvg(
|
||||
'tinymce/real.png',
|
||||
UploadedFile::fake()->createWithContent('real.png', $png)
|
||||
);
|
||||
|
||||
expect(Storage::get('tinymce/real.png'))->toBe($png);
|
||||
});
|
||||
92
tests/Pest.php
Normal file
92
tests/Pest.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
use Webkul\User\Models\User;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Test Case
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The closure you provide to your test functions is always bound to a specific PHPUnit test
|
||||
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
|
||||
| need to change it using the "uses()" function to bind a different classes or traits.
|
||||
|
|
||||
*/
|
||||
|
||||
uses(TestCase::class)->in('Feature');
|
||||
|
||||
/**
|
||||
* The configured APP_URL may point at a subdirectory (for example when the application is served
|
||||
* from `/public` under a project folder). Laravel's test client prepends that URL to every request
|
||||
* path, so the resulting path info would carry the subdirectory and no route would ever match,
|
||||
* failing every feature test with a 404. Pinning a bare root URL keeps request paths aligned with
|
||||
* the routes as registered, whatever APP_URL happens to be on the machine running the tests.
|
||||
*/
|
||||
uses()->beforeEach(function () {
|
||||
URL::forceRootUrl('http://localhost');
|
||||
})->in('Feature');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
|
||||
| project that you don't want to repeat in every file. Here you can also expose helpers as
|
||||
| global functions to help you to reduce the number of lines of code in your test files.
|
||||
|
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get default admin which is created on fresh instance.
|
||||
*
|
||||
* @return User
|
||||
*/
|
||||
function getDefaultAdmin()
|
||||
{
|
||||
$admin = User::find(1);
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanctum authenticated admin.
|
||||
*
|
||||
* @return User
|
||||
*/
|
||||
function actingAsSanctumAuthenticatedAdmin()
|
||||
{
|
||||
return Sanctum::actingAs(
|
||||
getDefaultAdmin(),
|
||||
['*']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get first name.
|
||||
*
|
||||
* @param string $fullName
|
||||
* @return string
|
||||
*/
|
||||
function getFirstName($fullName)
|
||||
{
|
||||
return explode(' ', $fullName)[0];
|
||||
}
|
||||
10
tests/TestCase.php
Normal file
10
tests/TestCase.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
}
|
||||
7
tests/Unit/BasicTest.php
Normal file
7
tests/Unit/BasicTest.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
test('check basic unit test', function () {
|
||||
$this->assertTrue(true);
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
Reference in New Issue
Block a user