first commit
This commit is contained in:
7
tests/Feature/ExampleTest.php
Normal file
7
tests/Feature/ExampleTest.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
test('returns a successful response', function () {
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
36
tests/Feature/Security/AccessControlTest.php
Normal file
36
tests/Feature/Security/AccessControlTest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PersonalData;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
|
||||
use function Pest\Laravel\seed;
|
||||
|
||||
beforeEach(fn () => seed(RolesAndPermissionsSeeder::class));
|
||||
|
||||
it('разграничивает доступ к ПДн по ролям (УПД.2, УПД.5)', function () {
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$owner->assignRole('user');
|
||||
|
||||
$stranger = User::factory()->create();
|
||||
$stranger->assignRole('user');
|
||||
|
||||
$data = PersonalData::create(['owner_id' => $owner->id, 'last_name' => 'Сидоров']);
|
||||
|
||||
expect($admin->can('view', $data))->toBeTrue()
|
||||
->and($owner->can('view', $data))->toBeTrue()
|
||||
->and($stranger->can('view', $data))->toBeFalse();
|
||||
});
|
||||
|
||||
it('аудитор не имеет прав на работу с ПДн (разделение обязанностей)', function () {
|
||||
$auditor = User::factory()->create();
|
||||
$auditor->assignRole('auditor');
|
||||
|
||||
$data = PersonalData::create(['last_name' => 'Кузнецов']);
|
||||
|
||||
expect($auditor->can('view', $data))->toBeFalse()
|
||||
->and($auditor->hasPermissionTo('audit.view'))->toBeTrue();
|
||||
});
|
||||
33
tests/Feature/Security/AuditLogTest.php
Normal file
33
tests/Feature/Security/AuditLogTest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use App\Facades\Audit;
|
||||
use App\Models\AuditLog;
|
||||
|
||||
it('подписывает записи журнала и подтверждает целостность (РСБ.3)', function () {
|
||||
Audit::log('pdn.viewed', 'read', 'PersonalData:1');
|
||||
Audit::log('pdn.updated', 'update', 'PersonalData:1');
|
||||
|
||||
expect(AuditLog::count())->toBe(2)
|
||||
->and(Audit::verifyIntegrity())->toBe([]);
|
||||
});
|
||||
|
||||
it('обнаруживает подделку записи журнала (РСБ.3)', function () {
|
||||
Audit::log('pdn.viewed', 'read', 'PersonalData:1');
|
||||
$entry = Audit::log('pdn.updated', 'update', 'PersonalData:1');
|
||||
|
||||
// Изменение записи в обход подписи.
|
||||
$entry->result = 'failure';
|
||||
$entry->saveQuietly();
|
||||
|
||||
expect(Audit::verifyIntegrity())->toContain($entry->id);
|
||||
});
|
||||
|
||||
it('обнаруживает разрыв хеш-цепочки при удалении записи (РСБ.3)', function () {
|
||||
Audit::log('e1', 'a', 'R:1');
|
||||
$second = Audit::log('e2', 'a', 'R:2');
|
||||
Audit::log('e3', 'a', 'R:3');
|
||||
|
||||
$second->delete();
|
||||
|
||||
expect(Audit::verifyIntegrity())->not->toBe([]);
|
||||
});
|
||||
41
tests/Feature/Security/AuthenticationTest.php
Normal file
41
tests/Feature/Security/AuthenticationTest.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
|
||||
use function Pest\Laravel\post;
|
||||
use function Pest\Laravel\seed;
|
||||
|
||||
beforeEach(fn () => seed(RolesAndPermissionsSeeder::class));
|
||||
|
||||
it('блокирует вход после превышения числа попыток (ИАФ.6)', function () {
|
||||
$user = User::factory()->create(['email' => 'lock@example.local']);
|
||||
$max = (int) config('security.lockout.max_attempts');
|
||||
|
||||
for ($i = 0; $i < $max; $i++) {
|
||||
post('/login', ['email' => $user->email, 'password' => 'wrong-password']);
|
||||
}
|
||||
|
||||
// Превышение лимита: попытка отклоняется и фиксируется блокировка (РСБ.2).
|
||||
post('/login', ['email' => $user->email, 'password' => 'wrong-password'])
|
||||
->assertSessionHasErrors('email');
|
||||
|
||||
$this->assertDatabaseHas('audit_log', ['event_type' => 'auth.locked'], config('audit.connection'));
|
||||
});
|
||||
|
||||
it('регистрирует неудачный вход в журнале аудита (РСБ.2)', function () {
|
||||
User::factory()->create(['email' => 'audit-login@example.local']);
|
||||
|
||||
post('/login', ['email' => 'audit-login@example.local', 'password' => 'wrong']);
|
||||
|
||||
$this->assertDatabaseHas('audit_log', ['event_type' => 'auth.login.failed'], config('audit.connection'));
|
||||
});
|
||||
|
||||
it('требует второй фактор после входа, если MFA не настроена (ИАФ.4)', function () {
|
||||
$user = User::factory()->create(['password' => bcrypt('Password123!@#')]);
|
||||
|
||||
post('/login', ['email' => $user->email, 'password' => 'Password123!@#']);
|
||||
|
||||
// Доступ в защищённую зону перенаправляет на настройку MFA.
|
||||
$this->get('/dashboard')->assertRedirect(route('mfa.setup'));
|
||||
});
|
||||
42
tests/Feature/Security/PasswordPolicyTest.php
Normal file
42
tests/Feature/Security/PasswordPolicyTest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Rules\PasswordNotReused;
|
||||
use App\Services\Auth\PasswordManager;
|
||||
use App\Support\PasswordPolicy;
|
||||
|
||||
it('запрещает повторное использование последних паролей (ИАФ.3)', function () {
|
||||
$user = User::factory()->create();
|
||||
$manager = app(PasswordManager::class);
|
||||
|
||||
$manager->change($user, 'FirstPass123!@#');
|
||||
$manager->change($user, 'SecondPass123!@#');
|
||||
|
||||
$rule = new PasswordNotReused($user->fresh());
|
||||
|
||||
$failed = false;
|
||||
$rule->validate('password', 'FirstPass123!@#', function () use (&$failed) {
|
||||
$failed = true;
|
||||
});
|
||||
|
||||
expect($failed)->toBeTrue();
|
||||
});
|
||||
|
||||
it('определяет истёкший срок действия пароля (ИАФ.3)', function () {
|
||||
$expired = User::factory()->passwordExpired()->create();
|
||||
$fresh = User::factory()->create();
|
||||
|
||||
expect($expired->passwordExpired())->toBeTrue()
|
||||
->and($fresh->passwordExpired())->toBeFalse();
|
||||
});
|
||||
|
||||
it('применяет минимальную длину пароля из политики (ИАФ.3)', function () {
|
||||
$rule = PasswordPolicy::rule();
|
||||
|
||||
$validator = validator(
|
||||
['password' => 'Short1!'],
|
||||
['password' => $rule],
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
30
tests/Feature/Security/PdnEncryptionTest.php
Normal file
30
tests/Feature/Security/PdnEncryptionTest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PersonalData;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('хранит поля ПДн в зашифрованном виде (ЗНИ)', function () {
|
||||
$pd = PersonalData::create([
|
||||
'last_name' => 'Иванов',
|
||||
'passport' => '1234 567890',
|
||||
]);
|
||||
|
||||
// В приложении значение доступно в открытом виде.
|
||||
expect($pd->fresh()->last_name)->toBe('Иванов');
|
||||
|
||||
// В БД хранится шифртекст, а не исходное значение.
|
||||
$raw = DB::table('personal_data')->where('id', $pd->id)->value('last_name');
|
||||
expect($raw)->not->toBe('Иванов')
|
||||
->and(strlen((string) $raw))->toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('контролирует целостность записи ПДн (ОЦЛ.2)', function () {
|
||||
$pd = PersonalData::create(['last_name' => 'Петров']);
|
||||
|
||||
expect($pd->integrityValid())->toBeTrue();
|
||||
|
||||
// Подмена шифртекста в обход приложения нарушает контрольную сумму.
|
||||
DB::table('personal_data')->where('id', $pd->id)->update(['last_name' => 'tampered']);
|
||||
|
||||
expect(PersonalData::find($pd->id)->integrityValid())->toBeFalse();
|
||||
});
|
||||
13
tests/Feature/Security/SecurityHeadersTest.php
Normal file
13
tests/Feature/Security/SecurityHeadersTest.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use function Pest\Laravel\get;
|
||||
|
||||
it('устанавливает заголовки безопасности (ЗИС)', function () {
|
||||
$response = get('/');
|
||||
|
||||
$response->assertHeader('X-Frame-Options', 'DENY')
|
||||
->assertHeader('X-Content-Type-Options', 'nosniff')
|
||||
->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
expect($response->headers->get('Content-Security-Policy'))->toContain("default-src 'self'");
|
||||
});
|
||||
54
tests/Pest.php
Normal file
54
tests/Pest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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 "pest()" function to bind different classes or traits.
|
||||
|
|
||||
*/
|
||||
|
||||
pest()->extend(TestCase::class)
|
||||
->use(RefreshDatabase::class)
|
||||
->in('Feature');
|
||||
|
||||
pest()->extend(TestCase::class)
|
||||
->use(RefreshDatabase::class)
|
||||
->in('Unit');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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.
|
||||
|
|
||||
*/
|
||||
|
||||
function something()
|
||||
{
|
||||
// ..
|
||||
}
|
||||
21
tests/TestCase.php
Normal file
21
tests/TestCase.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Журнал аудита хранится в отдельном соединении (мера РСБ.3). В тестах
|
||||
// это in-memory sqlite — создаём схему журнала для каждого теста.
|
||||
Artisan::call('migrate:fresh', [
|
||||
'--database' => config('audit.connection'),
|
||||
'--path' => 'database/migrations/audit',
|
||||
]);
|
||||
}
|
||||
}
|
||||
5
tests/Unit/ExampleTest.php
Normal file
5
tests/Unit/ExampleTest.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
test('that true is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
Reference in New Issue
Block a user