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