first commit

This commit is contained in:
brusnitsyn
2026-06-24 17:20:43 +09:00
commit 43499acf1c
165 changed files with 25929 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
<?php
test('returns a successful response', function () {
$response = $this->get(route('home'));
$response->assertOk();
});

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

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
<?php
test('that true is true', function () {
expect(true)->toBeTrue();
});