Files
Gnuboard7/tests/Unit/Services/IdentityPolicyServiceTest.php
2026-05-11 11:29:41 +09:00

322 lines
11 KiB
PHP

<?php
namespace Tests\Unit\Services;
use App\Enums\IdentityVerificationStatus;
use App\Exceptions\IdentityVerificationRequiredException;
use App\Models\IdentityPolicy;
use App\Models\IdentityVerificationLog;
use App\Models\User;
use App\Services\IdentityPolicyService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
/**
* IdentityPolicyService 테스트.
*
* resolve/enforce 동작 및 grace_minutes/fail_mode 분기를 검증합니다.
*/
class IdentityPolicyServiceTest extends TestCase
{
use RefreshDatabase;
private IdentityPolicyService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = $this->app->make(IdentityPolicyService::class);
}
public function test_resolve_returns_policy_by_scope_target(): void
{
$policy = IdentityPolicy::create([
'key' => 'test.policy.a',
'scope' => 'route',
'target' => 'api.test.one',
'purpose' => 'sensitive_action',
'grace_minutes' => 0,
'enabled' => true,
'priority' => 100,
'source_type' => 'admin',
'source_identifier' => 'admin',
'applies_to' => 'both',
'fail_mode' => 'block',
]);
$resolved = $this->service->resolve('route', 'api.test.one');
$this->assertNotNull($resolved);
$this->assertSame($policy->key, $resolved->key);
}
public function test_resolve_returns_null_when_no_match(): void
{
$resolved = $this->service->resolve('route', 'api.nonexistent');
$this->assertNull($resolved);
}
public function test_enforce_throws_when_no_recent_verified(): void
{
$policy = $this->makePolicy(['grace_minutes' => 0]);
$this->expectException(IdentityVerificationRequiredException::class);
$this->service->enforce($policy, null, ['target_email' => 'x@example.com']);
}
public function test_enforce_passes_when_recent_verified_exists(): void
{
$policy = $this->makePolicy(['grace_minutes' => 5]);
$user = User::factory()->create();
IdentityVerificationLog::create([
'id' => (string) Str::uuid(),
'provider_id' => 'g7:core.mail',
'purpose' => 'sensitive_action',
'channel' => 'email',
'user_id' => $user->id,
'target_hash' => hash('sha256', mb_strtolower($user->email)),
'status' => IdentityVerificationStatus::Verified->value,
'verified_at' => now(),
'expires_at' => now()->addMinutes(15),
]);
// 예외가 발생하지 않으면 통과
$this->service->enforce($policy, $user, []);
$this->assertTrue(true);
}
public function test_enforce_log_only_does_not_throw_but_records_log(): void
{
$policy = $this->makePolicy(['grace_minutes' => 0, 'fail_mode' => 'log_only']);
$this->service->enforce($policy, null, ['target_email' => 'x@example.com']);
$this->assertDatabaseHas('identity_verification_logs', [
'status' => IdentityVerificationStatus::PolicyViolationLogged->value,
'origin_policy_key' => $policy->key,
]);
}
public function test_enforce_skips_when_policy_disabled(): void
{
$policy = $this->makePolicy(['enabled' => false]);
// 예외 없이 통과해야 함
$this->service->enforce($policy, null, ['target_email' => 'x@example.com']);
$this->assertTrue(true);
}
/**
* applies_to=self 정책은 admin 사용자(context.user_is_admin=true)에게는 enforce 하지 않습니다.
*/
public function test_enforce_skips_admin_user_when_applies_to_self(): void
{
$policy = $this->makePolicy(['grace_minutes' => 0, 'applies_to' => 'self']);
$this->service->enforce($policy, null, [
'target_email' => 'x@example.com',
'user_is_admin' => true,
]);
$this->assertTrue(true);
}
/**
* applies_to=admin 정책은 일반 사용자(user_is_admin=false)에게는 enforce 하지 않습니다.
*/
public function test_enforce_skips_non_admin_when_applies_to_admin(): void
{
$policy = $this->makePolicy(['grace_minutes' => 0, 'applies_to' => 'admin']);
$this->service->enforce($policy, null, [
'target_email' => 'x@example.com',
'user_is_admin' => false,
]);
$this->assertTrue(true);
}
/**
* applies_to=admin + admin context — 일반 enforce 흐름 적용 (verified 없으면 throw).
*/
public function test_enforce_throws_for_admin_when_applies_to_admin_and_no_verified(): void
{
$policy = $this->makePolicy(['grace_minutes' => 0, 'applies_to' => 'admin']);
$this->expectException(IdentityVerificationRequiredException::class);
$this->service->enforce($policy, null, [
'target_email' => 'x@example.com',
'user_is_admin' => true,
]);
}
/**
* applies_to=both — admin/일반 무관 enforce 흐름 적용.
*/
public function test_enforce_applies_to_both_targets_everyone(): void
{
$policy = $this->makePolicy(['grace_minutes' => 0, 'applies_to' => 'both']);
$this->expectException(IdentityVerificationRequiredException::class);
$this->service->enforce($policy, null, [
'target_email' => 'x@example.com',
'user_is_admin' => true,
]);
}
/**
* conditions.signup_stage 매칭 — context 가 일치할 때만 정책 선택됨.
*/
public function test_resolve_matches_signup_stage_condition(): void
{
IdentityPolicy::create([
'key' => 'test.signup.before',
'scope' => 'route',
'target' => 'api.test.signup',
'purpose' => 'signup',
'grace_minutes' => 0,
'enabled' => true,
'priority' => 110,
'source_type' => 'admin',
'source_identifier' => 'admin',
'applies_to' => 'self',
'fail_mode' => 'block',
'conditions' => ['signup_stage' => 'before_submit'],
]);
$matched = $this->service->resolve('route', 'api.test.signup', ['signup_stage' => 'before_submit']);
$this->assertNotNull($matched);
$this->assertSame('test.signup.before', $matched->key);
$miss = $this->service->resolve('route', 'api.test.signup', ['signup_stage' => 'after_create']);
$this->assertNull($miss);
}
/**
* conditions.signup_stage 미설정 정책 — context 무관 매칭 (기존 동작).
*/
public function test_resolve_signup_stage_unset_matches_any_context(): void
{
IdentityPolicy::create([
'key' => 'test.signup.any',
'scope' => 'route',
'target' => 'api.test.signup_any',
'purpose' => 'signup',
'grace_minutes' => 0,
'enabled' => true,
'priority' => 100,
'source_type' => 'admin',
'source_identifier' => 'admin',
'applies_to' => 'self',
'fail_mode' => 'block',
]);
$resolved = $this->service->resolve('route', 'api.test.signup_any', ['signup_stage' => 'any_value']);
$this->assertNotNull($resolved);
}
/**
* filter 훅이 IdentityPolicy 외 타입(null/string)을 반환해도 원본 정책이 유지되어야 합니다 (보안).
*/
public function test_resolve_filter_hook_invalid_return_type_keeps_original(): void
{
$policy = IdentityPolicy::create([
'key' => 'test.filter.original',
'scope' => 'route',
'target' => 'api.test.filter',
'purpose' => 'sensitive_action',
'grace_minutes' => 0,
'enabled' => true,
'priority' => 100,
'source_type' => 'admin',
'source_identifier' => 'admin',
'applies_to' => 'both',
'fail_mode' => 'block',
]);
\App\Extension\HookManager::addFilter(
'core.identity.resolve_policy',
fn ($current) => null, // 의도적 우회 시도
10,
);
try {
$resolved = $this->service->resolve('route', 'api.test.filter');
$this->assertNotNull($resolved, 'filter 훅이 null 반환해도 원본 정책이 유지되어야 함');
$this->assertSame($policy->key, $resolved->key);
} finally {
\App\Extension\HookManager::clearFilter('core.identity.resolve_policy');
}
}
private function makePolicy(array $overrides = []): IdentityPolicy
{
return IdentityPolicy::create(array_merge([
'key' => 'test.policy.'.Str::random(4),
'scope' => 'route',
'target' => 'api.test.any',
'purpose' => 'sensitive_action',
'grace_minutes' => 0,
'enabled' => true,
'priority' => 100,
'source_type' => 'admin',
'source_identifier' => 'admin',
'applies_to' => 'both',
'fail_mode' => 'block',
], $overrides));
}
/**
* resetFieldOverride('conditions') — B안 리팩토링.
*
* 운영자가 conditions 를 수정한 후 "↺ 기본값으로 되돌리기" 클릭 시
* declared default 가 복원되고 user_overrides 에서 'conditions' 가 제거되어야 한다.
*/
public function test_reset_field_override_restores_declared_conditions(): void
{
// 주의: policy key 에 dot 가 포함되므로 config dot-notation 을 회피하기 위해
// 'core.identity_policies' 블록을 통째로 설정한다 (Service::findDeclaredDefault 와 동형).
config([
'core.identity_policies' => [
'test.reset.conditions' => [
'key' => 'test.reset.conditions',
'scope' => 'route',
'target' => 'api.auth.register',
'purpose' => 'signup',
'conditions' => ['signup_stage' => 'before_submit'],
'grace_minutes' => 0,
'enabled' => true,
'priority' => 100,
'applies_to' => 'self',
'fail_mode' => 'block',
],
],
]);
$policy = IdentityPolicy::create([
'key' => 'test.reset.conditions',
'scope' => 'route',
'target' => 'api.auth.register',
'purpose' => 'signup',
'grace_minutes' => 0,
'enabled' => true,
'priority' => 100,
'source_type' => 'core',
'source_identifier' => 'core',
'applies_to' => 'self',
'fail_mode' => 'block',
'conditions' => ['signup_stage' => 'after_create'],
'user_overrides' => ['conditions'],
]);
$result = $this->service->resetFieldOverride($policy, 'conditions');
$this->assertTrue($result);
$fresh = $policy->fresh();
$this->assertSame(['signup_stage' => 'before_submit'], $fresh->conditions);
$this->assertNotContains('conditions', $fresh->user_overrides ?? []);
}
}