roleRepository = Mockery::mock(RoleRepositoryInterface::class); $this->permissionRepository = Mockery::mock(PermissionRepositoryInterface::class); $this->helper = new ExtensionRoleSyncHelper( $this->roleRepository, $this->permissionRepository, ); } /** * Eloquent 모델 프로퍼티를 안전하게 설정하기 위한 헬퍼 * * @param string $modelClass 모델 클래스명 * @param array $attributes 설정할 속성 * @return \Mockery\MockInterface */ private function createModelMock(string $modelClass, array $attributes = []): \Mockery\MockInterface { $mock = Mockery::mock($modelClass)->makePartial(); foreach ($attributes as $key => $value) { $mock->{$key} = $value; } return $mock; } // ========================================================= // syncRole 테스트 // ========================================================= /** * 신규 역할 생성 시 user_overrides 없이 생성되는지 테스트 */ public function test_sync_role_creates_new_role_without_user_overrides(): void { $name = ['ko' => '게시판 관리자', 'en' => 'Board Manager']; $description = ['ko' => '게시판 관리 역할', 'en' => 'Board management role']; $this->roleRepository->shouldReceive('findByIdentifier') ->once() ->with('board-manager') ->andReturnNull(); $expectedRole = $this->createModelMock(Role::class); $this->roleRepository->shouldReceive('updateOrCreate') ->once() ->with( ['identifier' => 'board-manager'], Mockery::on(function ($values) use ($name, $description) { return $values['name'] === $name && $values['description'] === $description && $values['extension_type'] === ExtensionOwnerType::Module && $values['extension_identifier'] === 'sirsoft-board' && $values['is_active'] === true && ! array_key_exists('user_overrides', $values); }) ) ->andReturn($expectedRole); $result = $this->helper->syncRole( identifier: 'board-manager', newName: $name, newDescription: $description, extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', otherAttributes: ['is_active' => true], ); $this->assertSame($expectedRole, $result); } /** * user_overrides에 "name"이 있으면 name 갱신을 건너뛰는지 테스트 */ public function test_sync_role_skips_user_modified_name(): void { $newName = ['ko' => 'Board 관리자', 'en' => 'Board Admin']; $newDescription = ['ko' => '게시판 관리 역할 v2', 'en' => 'Board management role v2']; $existingRole = $this->createModelMock(Role::class, [ 'name' => ['ko' => '우리 게시판 관리자', 'en' => 'Our Board Manager'], 'description' => ['ko' => '이전 설명', 'en' => 'Previous description'], 'user_overrides' => ['name'], // 유저가 name을 수정한 적 있음 ]); $freshRole = $this->createModelMock(Role::class); $existingRole->shouldReceive('fresh')->once()->andReturn($freshRole); $this->roleRepository->shouldReceive('findByIdentifier') ->once() ->with('board-manager') ->andReturn($existingRole); $this->roleRepository->shouldReceive('update') ->once() ->with($existingRole, Mockery::on(function ($data) use ($newDescription) { // name은 user_overrides에 있으므로 포함되지 않아야 함 return ! array_key_exists('name', $data) && $data['description'] === $newDescription; })) ->andReturnTrue(); $result = $this->helper->syncRole( identifier: 'board-manager', newName: $newName, newDescription: $newDescription, extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', ); $this->assertSame($freshRole, $result); } /** * user_overrides에 "description"이 있으면 description 갱신을 건너뛰는지 테스트 */ public function test_sync_role_skips_user_modified_description(): void { $newName = ['ko' => 'Board 관리자', 'en' => 'Board Admin']; $newDescription = ['ko' => '게시판 관리 역할 v2', 'en' => 'Board management role v2']; $existingRole = $this->createModelMock(Role::class, [ 'name' => ['ko' => '게시판 관리자', 'en' => 'Board Manager'], 'description' => ['ko' => '유저가 수정한 설명', 'en' => 'User modified description'], 'user_overrides' => ['description'], // 유저가 description을 수정한 적 있음 ]); $freshRole = $this->createModelMock(Role::class); $existingRole->shouldReceive('fresh')->once()->andReturn($freshRole); $this->roleRepository->shouldReceive('findByIdentifier') ->once() ->with('board-manager') ->andReturn($existingRole); $this->roleRepository->shouldReceive('update') ->once() ->with($existingRole, Mockery::on(function ($data) use ($newName) { // description은 user_overrides에 있으므로 포함되지 않아야 함 return $data['name'] === $newName && ! array_key_exists('description', $data); })) ->andReturnTrue(); $result = $this->helper->syncRole( identifier: 'board-manager', newName: $newName, newDescription: $newDescription, extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', ); $this->assertSame($freshRole, $result); } /** * user_overrides에 없는 필드만 갱신되는지 테스트 */ public function test_sync_role_updates_unmodified_fields(): void { $newName = ['ko' => 'Board 관리자', 'en' => 'Board Admin']; $newDescription = ['ko' => '게시판 관리 역할 v2', 'en' => 'Board management role v2']; $existingRole = $this->createModelMock(Role::class, [ 'name' => ['ko' => '게시판 관리자', 'en' => 'Board Manager'], 'description' => ['ko' => '게시판 관리 역할', 'en' => 'Board management role'], 'user_overrides' => [], // 유저가 아무것도 수정하지 않음 ]); $freshRole = $this->createModelMock(Role::class); $existingRole->shouldReceive('fresh')->once()->andReturn($freshRole); $this->roleRepository->shouldReceive('findByIdentifier') ->once() ->with('board-manager') ->andReturn($existingRole); $this->roleRepository->shouldReceive('update') ->once() ->with($existingRole, Mockery::on(function ($data) use ($newName, $newDescription) { return $data['name'] === $newName && $data['description'] === $newDescription; })) ->andReturnTrue(); $result = $this->helper->syncRole( identifier: 'board-manager', newName: $newName, newDescription: $newDescription, extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', ); $this->assertSame($freshRole, $result); } /** * sync 시점에 user_overrides 자동 마킹이 비활성화되는지 테스트. * * 회귀 방지: HasUserOverrides trait 의 `updating` 이벤트 hook 은 * `app('user_overrides.seeding') === true` 일 때 자동 마킹을 건너뛴다. * Helper 가 update 호출을 seeding 컨텍스트로 wrap 하지 않으면 시스템 sync * 임에도 name/description 등 trackable 필드가 user_overrides 에 잘못 * 마킹되어 이후 sync 가 영구히 차단된다 (Menu sync 와 동일 결함). */ public function test_sync_role_disables_user_overrides_auto_marking_during_update(): void { $newName = ['ko' => 'Board 관리자', 'en' => 'Board Admin']; $newDescription = ['ko' => '게시판 관리 역할', 'en' => 'Board management role']; $existingRole = $this->createModelMock(Role::class, [ 'name' => ['ko' => '게시판 관리자', 'en' => 'Board Manager'], 'description' => ['ko' => '이전 설명', 'en' => 'Previous description'], 'user_overrides' => [], ]); $freshRole = $this->createModelMock(Role::class); $existingRole->shouldReceive('fresh')->once()->andReturn($freshRole); $this->roleRepository->shouldReceive('findByIdentifier') ->once() ->with('board-manager') ->andReturn($existingRole); $seedingFlagDuringUpdate = null; $this->roleRepository->shouldReceive('update') ->once() ->with($existingRole, Mockery::any()) ->andReturnUsing(function () use (&$seedingFlagDuringUpdate) { $seedingFlagDuringUpdate = app()->bound('user_overrides.seeding') && app('user_overrides.seeding') === true; return true; }); $this->helper->syncRole( identifier: 'board-manager', newName: $newName, newDescription: $newDescription, extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-board', ); $this->assertTrue( $seedingFlagDuringUpdate, 'update 호출 시점에 user_overrides.seeding 플래그가 true 여야 한다.' ); $this->assertFalse( app()->bound('user_overrides.seeding'), 'syncRole 종료 후 user_overrides.seeding 플래그가 정리되어야 한다.' ); } // ========================================================= // syncPermission 테스트 // ========================================================= /** * 권한은 항상 확장 정의값으로 덮어쓰는지 테스트 */ public function test_sync_permission_always_overwrites(): void { $name = ['ko' => '상품 조회', 'en' => 'View Products']; $description = ['ko' => '상품 목록을 조회합니다', 'en' => 'View product list']; $expectedPermission = $this->createModelMock(Permission::class); $this->permissionRepository->shouldReceive('updateOrCreate') ->once() ->with( ['identifier' => 'ecommerce.products.read'], Mockery::on(function ($values) use ($name, $description) { return $values['name'] === $name && $values['description'] === $description && $values['extension_type'] === ExtensionOwnerType::Module && $values['extension_identifier'] === 'sirsoft-ecommerce'; }) ) ->andReturn($expectedPermission); $result = $this->helper->syncPermission( identifier: 'ecommerce.products.read', newName: $name, newDescription: $description, extensionType: ExtensionOwnerType::Module, extensionIdentifier: 'sirsoft-ecommerce', ); $this->assertSame($expectedPermission, $result); } // ========================================================= // cleanupStalePermissions 테스트 // ========================================================= /** * stale 권한이 삭제되는지 테스트 (자식 포함) */ public function test_cleanup_stale_permissions_removes_deleted_permissions(): void { $currentPerm = $this->createModelMock(Permission::class, [ 'identifier' => 'ecommerce.products.read', ]); $childPerm = $this->createModelMock(Permission::class, [ 'identifier' => 'child-perm', ]); $childPermRoles = Mockery::mock(BelongsToMany::class); $childPermRoles->shouldReceive('detach')->once(); $childPerm->shouldReceive('roles')->once()->andReturn($childPermRoles); $stalePerm = $this->createModelMock(Permission::class, [ 'identifier' => 'ecommerce.old-feature.read', ]); $stalePermRoles = Mockery::mock(BelongsToMany::class); $stalePermRoles->shouldReceive('detach')->once(); $stalePerm->shouldReceive('roles')->once()->andReturn($stalePermRoles); $childrenCollection = new Collection([$childPerm]); $stalePerm->shouldReceive('getAttribute') ->with('children') ->andReturn($childrenCollection); $this->permissionRepository->shouldReceive('getByExtension') ->once() ->with(ExtensionOwnerType::Module, 'sirsoft-ecommerce') ->andReturn(new Collection([$currentPerm, $stalePerm])); $this->permissionRepository->shouldReceive('delete') ->once() ->with($childPerm) ->andReturnTrue(); $this->permissionRepository->shouldReceive('delete') ->once() ->with($stalePerm) ->andReturnTrue(); $deleted = $this->helper->cleanupStalePermissions( ExtensionOwnerType::Module, 'sirsoft-ecommerce', ['ecommerce.products.read'], ); $this->assertEquals(2, $deleted); } /** * 유효한 권한은 보존되는지 테스트 */ public function test_cleanup_stale_permissions_preserves_current_permissions(): void { $currentPerm = $this->createModelMock(Permission::class, [ 'identifier' => 'ecommerce.products.read', ]); $this->permissionRepository->shouldReceive('getByExtension') ->once() ->andReturn(new Collection([$currentPerm])); $this->permissionRepository->shouldNotReceive('delete'); $deleted = $this->helper->cleanupStalePermissions( ExtensionOwnerType::Module, 'sirsoft-ecommerce', ['ecommerce.products.read'], ); $this->assertEquals(0, $deleted); } // ========================================================= // syncAllRoleAssignments 테스트 (DB 기반 diff) // ========================================================= /** * 신규 권한이 DB 기반 diff로 attach되는지 테스트 */ public function test_sync_all_role_assignments_attaches_new_permissions(): void { $permissionsRelation = Mockery::mock(BelongsToMany::class); $permissionsRelation->shouldReceive('whereIn') ->with('identifier', ['board.read', 'board.create']) ->once() ->andReturnSelf(); $permissionsRelation->shouldReceive('pluck') ->with('identifier') ->once() ->andReturn(collect([])); // DB에 아직 없음 $adminRole = $this->createModelMock(Role::class, [ 'identifier' => 'admin', 'user_overrides' => [], ]); $adminRole->shouldReceive('permissions')->once()->andReturn($permissionsRelation); $readPerm = $this->createModelMock(Permission::class, ['id' => 1]); $createPerm = $this->createModelMock(Permission::class, ['id' => 2]); $this->roleRepository->shouldReceive('findByIdentifier') ->with('admin') ->once() ->andReturn($adminRole); $this->permissionRepository->shouldReceive('findByIdentifier') ->with('board.read') ->once() ->andReturn($readPerm); $this->permissionRepository->shouldReceive('findByIdentifier') ->with('board.create') ->once() ->andReturn($createPerm); $this->roleRepository->shouldReceive('attachPermission') ->once() ->with($adminRole, 1, Mockery::type('array')); $this->roleRepository->shouldReceive('attachPermission') ->once() ->with($adminRole, 2, Mockery::type('array')); $this->roleRepository->shouldNotReceive('detachPermission'); $this->helper->syncAllRoleAssignments( permissionRoleMap: [ 'board.read' => ['admin'], 'board.create' => ['admin'], ], allExtensionPermIdentifiers: ['board.read', 'board.create'], ); } /** * 제거된 권한이 DB 기반 diff로 detach되는지 테스트 */ public function test_sync_all_role_assignments_detaches_removed_permissions(): void { $permissionsRelation = Mockery::mock(BelongsToMany::class); $permissionsRelation->shouldReceive('whereIn') ->with('identifier', ['board.read', 'board.create']) ->once() ->andReturnSelf(); $permissionsRelation->shouldReceive('pluck') ->with('identifier') ->once() ->andReturn(collect(['board.read', 'board.create'])); // DB에 2개 있음 $adminRole = $this->createModelMock(Role::class, [ 'identifier' => 'admin', 'user_overrides' => [], ]); $adminRole->shouldReceive('permissions')->once()->andReturn($permissionsRelation); $createPerm = $this->createModelMock(Permission::class, ['id' => 2]); $this->roleRepository->shouldReceive('findByIdentifier') ->with('admin') ->once() ->andReturn($adminRole); // board.create는 새 정의에서 제거됨 → detach $this->permissionRepository->shouldReceive('findByIdentifier') ->with('board.create') ->once() ->andReturn($createPerm); $this->roleRepository->shouldNotReceive('attachPermission'); $this->roleRepository->shouldReceive('detachPermission') ->once() ->with($adminRole, 2); $this->helper->syncAllRoleAssignments( permissionRoleMap: [ 'board.read' => ['admin'], // board.create 제거 ], allExtensionPermIdentifiers: ['board.read', 'board.create'], ); } /** * user_overrides에 기록된 개별 권한 식별자만 보호되는지 테스트 * * 시나리오: user_overrides에 "board.delete"가 있고, 확장이 board.delete를 정의에서 제거함 * → board.delete는 보호되므로 detach하지 않음 * → board.read, board.create는 보호되지 않으므로 정상 동기화 */ public function test_sync_all_role_assignments_skips_user_overridden_permissions(): void { $permissionsRelation = Mockery::mock(BelongsToMany::class); $permissionsRelation->shouldReceive('whereIn') ->with('identifier', ['board.read', 'board.create', 'board.delete']) ->once() ->andReturnSelf(); $permissionsRelation->shouldReceive('pluck') ->with('identifier') ->once() ->andReturn(collect(['board.read', 'board.create', 'board.delete'])); // DB에 3개 있음 $adminRole = $this->createModelMock(Role::class, [ 'identifier' => 'admin', 'user_overrides' => ['board.delete'], // 유저가 board.delete를 변경한 적 있음 ]); $adminRole->shouldReceive('permissions')->once()->andReturn($permissionsRelation); $this->roleRepository->shouldReceive('findByIdentifier') ->with('admin') ->once() ->andReturn($adminRole); // board.delete는 보호됨 → detach 안 됨 // board.read, board.create는 정의에 포함 + DB에 있음 → 변경 없음 $this->roleRepository->shouldNotReceive('attachPermission'); $this->roleRepository->shouldNotReceive('detachPermission'); $this->helper->syncAllRoleAssignments( permissionRoleMap: [ 'board.read' => ['admin'], 'board.create' => ['admin'], // board.delete는 정의에서 제거됨 → 보호되므로 detach 안 됨 ], allExtensionPermIdentifiers: ['board.read', 'board.create', 'board.delete'], ); } /** * user_overrides에 없는 권한은 정상적으로 동기화되는지 테스트 * * 시나리오: user_overrides에 "board.delete"만 있고, 확장 업데이트로 board.update 추가 * → board.delete는 보호 (현재 상태 유지) * → board.update는 보호되지 않으므로 새로 attach */ public function test_sync_all_role_assignments_syncs_non_overridden_permissions(): void { $permissionsRelation = Mockery::mock(BelongsToMany::class); $permissionsRelation->shouldReceive('whereIn') ->with('identifier', ['board.read', 'board.create', 'board.update', 'board.delete']) ->once() ->andReturnSelf(); $permissionsRelation->shouldReceive('pluck') ->with('identifier') ->once() ->andReturn(collect(['board.read', 'board.create'])); // DB에 2개만 있음 (유저가 board.delete 해제) $adminRole = $this->createModelMock(Role::class, [ 'identifier' => 'admin', 'user_overrides' => ['board.delete'], // 유저가 board.delete를 변경한 적 있음 ]); $adminRole->shouldReceive('permissions')->once()->andReturn($permissionsRelation); $updatePerm = $this->createModelMock(Permission::class, ['id' => 4]); $this->roleRepository->shouldReceive('findByIdentifier') ->with('admin') ->once() ->andReturn($adminRole); // board.update는 새 정의에 있고 DB에 없고 보호되지 않음 → attach $this->permissionRepository->shouldReceive('findByIdentifier') ->with('board.update') ->once() ->andReturn($updatePerm); $this->roleRepository->shouldReceive('attachPermission') ->once() ->with($adminRole, 4, Mockery::type('array')); // board.delete는 보호됨 → 정의에 있더라도 detach 안 됨 $this->roleRepository->shouldNotReceive('detachPermission'); $this->helper->syncAllRoleAssignments( permissionRoleMap: [ 'board.read' => ['admin'], 'board.create' => ['admin'], 'board.update' => ['admin'], // 신규 추가 'board.delete' => ['admin'], // 확장은 다시 정의하지만, 유저가 해제한 상태이므로 보호 ], allExtensionPermIdentifiers: ['board.read', 'board.create', 'board.update', 'board.delete'], ); } /** * DB 기반 diff로 정확한 attach/detach가 이루어지는지 테스트 * * 시나리오: DB에 [board.read] 있음, 정의에 [board.read, board.create] * → board.create만 attach, detach 없음 */ public function test_sync_all_role_assignments_uses_db_based_diff(): void { $permissionsRelation = Mockery::mock(BelongsToMany::class); $permissionsRelation->shouldReceive('whereIn') ->with('identifier', ['board.read', 'board.create']) ->once() ->andReturnSelf(); $permissionsRelation->shouldReceive('pluck') ->with('identifier') ->once() ->andReturn(collect(['board.read'])); // DB에 board.read만 있음 $adminRole = $this->createModelMock(Role::class, [ 'identifier' => 'admin', 'user_overrides' => [], ]); $adminRole->shouldReceive('permissions')->once()->andReturn($permissionsRelation); $createPerm = $this->createModelMock(Permission::class, ['id' => 2]); $this->roleRepository->shouldReceive('findByIdentifier') ->with('admin') ->once() ->andReturn($adminRole); // board.create만 attach (board.read는 이미 DB에 있으므로 건너뜀) $this->permissionRepository->shouldReceive('findByIdentifier') ->with('board.create') ->once() ->andReturn($createPerm); $this->roleRepository->shouldReceive('attachPermission') ->once() ->with($adminRole, 2, Mockery::type('array')); $this->roleRepository->shouldNotReceive('detachPermission'); $this->helper->syncAllRoleAssignments( permissionRoleMap: [ 'board.read' => ['admin'], 'board.create' => ['admin'], ], allExtensionPermIdentifiers: ['board.read', 'board.create'], ); } }