Files
Gnuboard7/tests/Feature/Api/Admin/Identity/AdminIdentityMessageDefinitionAdminCrudTest.php
T
2026-05-14 11:13:08 +09:00

339 lines
12 KiB
PHP

<?php
namespace Tests\Feature\Api\Admin\Identity;
use App\Models\IdentityMessageDefinition;
use App\Models\IdentityMessageTemplate;
use App\Models\IdentityPolicy;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Database\Seeders\IdentityMessageDefinitionSeeder;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 관리자 — IDV 메시지 정의 admin CRUD (#297).
*
* 정책 매핑 정의 운영자 추가/삭제 + 시드 정의 보호 가드 검증.
*/
class AdminIdentityMessageDefinitionAdminCrudTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private string $token;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
$this->seed(IdentityMessageDefinitionSeeder::class);
$this->admin = User::factory()->create(['is_super' => true]);
$adminRole = Role::where('identifier', 'admin')->first();
if ($adminRole) {
$this->admin->roles()->attach($adminRole->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
}
$this->admin = $this->admin->fresh();
$this->token = $this->admin->createToken('test-token')->plainTextToken;
}
private function authRequest(): static
{
return $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
]);
}
private function makeAdminPolicy(string $key = 'admin.test.policy'): IdentityPolicy
{
return IdentityPolicy::create([
'key' => $key,
'scope' => 'route',
'target' => 'api.test.route',
'purpose' => 'sensitive_action',
'grace_minutes' => 0,
'enabled' => true,
'priority' => 100,
'conditions' => [],
'applies_to' => 'both',
'fail_mode' => 'block',
'source_type' => 'admin',
'source_identifier' => 'admin',
]);
}
private function validPayload(string $policyKey = 'admin.test.policy'): array
{
return [
'provider_id' => 'g7:core.mail',
'scope_type' => IdentityMessageDefinition::SCOPE_POLICY,
'scope_value' => $policyKey,
'name' => ['ko' => '커스텀 정책 메시지', 'en' => 'Custom Policy Message'],
'description' => ['ko' => '운영자 정책', 'en' => 'Admin policy'],
'channels' => ['mail'],
'variables' => [['key' => 'code', 'description' => '인증 코드']],
'templates' => [
[
'channel' => 'mail',
'subject' => ['ko' => '인증 코드 안내', 'en' => 'Verification Code'],
'body' => ['ko' => '<p>코드: {code}</p>', 'en' => '<p>Code: {code}</p>'],
],
],
];
}
public function test_admin_creates_definition_for_admin_policy(): void
{
$this->makeAdminPolicy();
$response = $this->authRequest()
->postJson('/api/admin/identity/messages/definitions', $this->validPayload());
$response->assertStatus(201);
$response->assertJsonPath('success', true);
$response->assertJsonPath('data.scope_type', 'policy');
$response->assertJsonPath('data.scope_value', 'admin.test.policy');
$response->assertJsonPath('data.is_default', false);
$response->assertJsonPath('data.is_active', true);
$response->assertJsonPath('data.abilities.can_delete', true);
$this->assertDatabaseHas('identity_message_definitions', [
'provider_id' => 'g7:core.mail',
'scope_type' => 'policy',
'scope_value' => 'admin.test.policy',
'is_default' => false,
'extension_identifier' => 'admin',
]);
$definition = IdentityMessageDefinition::where('scope_value', 'admin.test.policy')->firstOrFail();
$this->assertCount(1, $definition->templates);
$this->assertSame('mail', $definition->templates[0]->channel);
}
public function test_rejects_non_admin_policy_key(): void
{
// admin.test.policy 정책 미생성 → 매칭 실패
$response = $this->authRequest()
->postJson('/api/admin/identity/messages/definitions', $this->validPayload());
$response->assertStatus(422);
$response->assertJsonValidationErrors(['scope_value']);
}
public function test_rejects_core_source_policy_key(): void
{
// 코어 시드 정책(source_type='core')은 매칭 거부
IdentityPolicy::create([
'key' => 'core.seeded.policy',
'scope' => 'route',
'target' => 'api.core.route',
'purpose' => 'sensitive_action',
'grace_minutes' => 0,
'enabled' => true,
'priority' => 0,
'conditions' => [],
'applies_to' => 'both',
'fail_mode' => 'block',
'source_type' => 'core',
'source_identifier' => 'core',
]);
$payload = $this->validPayload('core.seeded.policy');
$response = $this->authRequest()
->postJson('/api/admin/identity/messages/definitions', $payload);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['scope_value']);
}
public function test_rejects_non_policy_scope_type(): void
{
$this->makeAdminPolicy();
foreach (['provider_default', 'purpose'] as $scopeType) {
$payload = $this->validPayload();
$payload['scope_type'] = $scopeType;
$response = $this->authRequest()
->postJson('/api/admin/identity/messages/definitions', $payload);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['scope_type']);
}
}
public function test_rejects_unknown_provider(): void
{
$this->makeAdminPolicy();
$payload = $this->validPayload();
$payload['provider_id'] = 'unknown:provider';
$response = $this->authRequest()
->postJson('/api/admin/identity/messages/definitions', $payload);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['provider_id']);
}
public function test_rejects_duplicate_provider_scope_combination(): void
{
$this->makeAdminPolicy();
// 첫 정의 생성
$this->authRequest()
->postJson('/api/admin/identity/messages/definitions', $this->validPayload())
->assertStatus(201);
// 동일 (provider, scope_type, scope_value) 두 번째 생성 → 422
$response = $this->authRequest()
->postJson('/api/admin/identity/messages/definitions', $this->validPayload());
$response->assertStatus(422);
$response->assertJsonValidationErrors(['scope_value']);
}
public function test_admin_deletes_admin_added_definition(): void
{
$this->makeAdminPolicy();
$this->authRequest()
->postJson('/api/admin/identity/messages/definitions', $this->validPayload())
->assertStatus(201);
$definition = IdentityMessageDefinition::where('scope_value', 'admin.test.policy')->firstOrFail();
$templateId = $definition->templates[0]->id;
$response = $this->authRequest()
->deleteJson('/api/admin/identity/messages/definitions/'.$definition->id);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$this->assertDatabaseMissing('identity_message_definitions', ['id' => $definition->id]);
// FK cascadeOnDelete 로 자식 templates 도 함께 삭제되어야 함
$this->assertDatabaseMissing('identity_message_templates', ['id' => $templateId]);
}
public function test_rejects_deletion_of_seeded_definition(): void
{
// 시더에서 생성된 default 정의 (provider_default scope) 삭제 시도
$seededDefinition = IdentityMessageDefinition::where('is_default', true)->firstOrFail();
$response = $this->authRequest()
->deleteJson('/api/admin/identity/messages/definitions/'.$seededDefinition->id);
$response->assertStatus(403);
$this->assertDatabaseHas('identity_message_definitions', ['id' => $seededDefinition->id]);
}
public function test_resource_can_delete_is_false_for_seeded_definitions(): void
{
$response = $this->authRequest()
->getJson('/api/admin/identity/messages/definitions');
$response->assertStatus(200);
$rows = $response->json('data.data');
$this->assertNotEmpty($rows);
foreach ($rows as $row) {
if ($row['is_default'] === true) {
$this->assertSame(false, $row['abilities']['can_delete'] ?? null,
"Seeded definition {$row['provider_id']}/{$row['scope_value']} should not be deletable");
}
}
}
public function test_unauthorized_user_cannot_create(): void
{
$this->makeAdminPolicy();
$response = $this->withHeaders(['Accept' => 'application/json'])
->postJson('/api/admin/identity/messages/definitions', $this->validPayload());
$response->assertStatus(401);
}
public function test_returns_404_for_unknown_definition_destroy(): void
{
$response = $this->authRequest()
->deleteJson('/api/admin/identity/messages/definitions/999999');
$response->assertStatus(404);
}
/**
* 컬렉션 응답에 can_create abilities 키가 발행된다 (운영자/슈퍼관리자).
*
* 회귀 (#361): 컬렉션 abilityMap 이 can_update 만 발행하던 시기에는
* 레이아웃의 "정의 추가" 버튼이 `abilities?.can_create !== true` 가드로
* 항상 disabled 되었다. 컬렉션 abilityMap 보강 검증.
*/
public function test_collection_publishes_can_create_ability(): void
{
$response = $this->authRequest()
->getJson('/api/admin/identity/messages/definitions');
$response->assertStatus(200);
$response->assertJsonPath('data.abilities.can_create', true);
$response->assertJsonPath('data.abilities.can_update', true);
}
/**
* 조회 권한(`*.read`)만 부여된 사용자는 컬렉션 abilities + per-item abilities 가
* 모두 false 로 발행되어, 프론트에서 편집/Toggle/Reset/삭제/정의 추가 액션을
* 잠가야 한다.
*
* 회귀 (#361): 레이아웃이 abilities 가드 없이 항상 버튼을 노출하던 결함.
*/
public function test_read_only_user_receives_falsey_abilities(): void
{
$readPermission = Permission::where('identifier', 'core.admin.identity.messages.read')->firstOrFail();
$viewerRole = Role::create([
'identifier' => 'idv-msg-viewer',
'name' => ['ko' => 'IDV 메시지 조회 전용', 'en' => 'IDV Message Viewer'],
'description' => ['ko' => '본인인증 메시지 조회 전용', 'en' => 'IDV Message read-only'],
'is_system' => false,
]);
$viewerRole->permissions()->attach($readPermission->id);
$viewer = User::factory()->create(['is_super' => false]);
$viewer->roles()->attach($viewerRole->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
$viewerToken = $viewer->createToken('viewer-token')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$viewerToken,
'Accept' => 'application/json',
])->getJson('/api/admin/identity/messages/definitions');
$response->assertStatus(200);
// 컬렉션 abilities — 정의 추가 가드용
$response->assertJsonPath('data.abilities.can_create', false);
$response->assertJsonPath('data.abilities.can_update', false);
// per-item abilities — 편집/Toggle/Reset/삭제 가드용
$rows = $response->json('data.data');
$this->assertNotEmpty($rows);
foreach ($rows as $row) {
$this->assertSame(false, $row['abilities']['can_update'] ?? null,
"Read-only user must not receive can_update=true (def {$row['provider_id']}/{$row['scope_value']})");
$this->assertSame(false, $row['abilities']['can_delete'] ?? null,
"Read-only user must not receive can_delete=true (def {$row['provider_id']}/{$row['scope_value']})");
}
}
}