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

This commit is contained in:
upstream-mirror
2026-09-18 04:37:41 +05:00
commit 135ffdb085
1274 changed files with 144880 additions and 0 deletions

View 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();
});

View 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');
});

View 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");
}
});

View 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('');
});

View 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();
});

View 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();
});

View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
<?php
test('check basic unit test', function () {
$this->assertTrue(true);
expect(true)->toBeTrue();
});