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' => '
코드: {code}
', 'en' => 'Code: {code}
'], ], ], ]; } 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']})"); } } }